diff --git a/.coveragerc b/.coveragerc
index ceff3384202..1ccb9e461df 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -361,6 +361,8 @@ omit =
homeassistant/components/environment_canada/weather.py
homeassistant/components/envisalink/*
homeassistant/components/ephember/climate.py
+ homeassistant/components/epic_games_store/__init__.py
+ homeassistant/components/epic_games_store/coordinator.py
homeassistant/components/epion/__init__.py
homeassistant/components/epion/coordinator.py
homeassistant/components/epion/sensor.py
@@ -739,6 +741,7 @@ omit =
homeassistant/components/lutron/binary_sensor.py
homeassistant/components/lutron/cover.py
homeassistant/components/lutron/entity.py
+ homeassistant/components/lutron/event.py
homeassistant/components/lutron/fan.py
homeassistant/components/lutron/light.py
homeassistant/components/lutron/switch.py
@@ -983,6 +986,7 @@ omit =
homeassistant/components/orvibo/switch.py
homeassistant/components/osoenergy/__init__.py
homeassistant/components/osoenergy/const.py
+ homeassistant/components/osoenergy/sensor.py
homeassistant/components/osoenergy/water_heater.py
homeassistant/components/osramlightify/light.py
homeassistant/components/otp/sensor.py
@@ -1154,8 +1158,10 @@ omit =
homeassistant/components/roborock/coordinator.py
homeassistant/components/rocketchat/notify.py
homeassistant/components/romy/__init__.py
+ homeassistant/components/romy/binary_sensor.py
homeassistant/components/romy/coordinator.py
homeassistant/components/romy/entity.py
+ homeassistant/components/romy/sensor.py
homeassistant/components/romy/vacuum.py
homeassistant/components/roomba/__init__.py
homeassistant/components/roomba/binary_sensor.py
@@ -1405,11 +1411,6 @@ omit =
homeassistant/components/tado/water_heater.py
homeassistant/components/tami4/button.py
homeassistant/components/tank_utility/sensor.py
- homeassistant/components/tankerkoenig/__init__.py
- homeassistant/components/tankerkoenig/binary_sensor.py
- homeassistant/components/tankerkoenig/coordinator.py
- homeassistant/components/tankerkoenig/entity.py
- homeassistant/components/tankerkoenig/sensor.py
homeassistant/components/tapsaff/binary_sensor.py
homeassistant/components/tautulli/__init__.py
homeassistant/components/tautulli/coordinator.py
diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml
index f02a8bacce8..a72c4e75cfe 100644
--- a/.github/workflows/builder.yml
+++ b/.github/workflows/builder.yml
@@ -27,7 +27,7 @@ jobs:
publish: ${{ steps.version.outputs.publish }}
steps:
- name: Checkout the repository
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
with:
fetch-depth: 0
@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: translations
path: translations.tar.gz
@@ -90,7 +90,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
- uses: actions/download-artifact@v4.1.4
+ uses: actions/download-artifact@v4.1.7
with:
name: translations
@@ -242,7 +242,7 @@ jobs:
- green
steps:
- name: Checkout the repository
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set build additional args
run: |
@@ -279,7 +279,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master
@@ -320,7 +320,7 @@ jobs:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Checkout the repository
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Install Cosign
uses: sigstore/cosign-installer@v3.4.0
@@ -450,7 +450,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0
@@ -458,7 +458,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
- uses: actions/download-artifact@v4.1.4
+ uses: actions/download-artifact@v4.1.7
with:
name: translations
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index a5bafa0c52d..580aba9752c 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -33,10 +33,10 @@ on:
type: boolean
env:
- CACHE_VERSION: 7
+ CACHE_VERSION: 8
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 8
- HA_SHORT_VERSION: "2024.5"
+ HA_SHORT_VERSION: "2024.6"
DEFAULT_PYTHON: "3.12"
ALL_PYTHON_VERSIONS: "['3.12']"
# 10.3 is the oldest supported version
@@ -89,7 +89,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Generate partial Python venv restore key
id: generate_python_cache_key
run: >-
@@ -97,7 +97,8 @@ jobs:
hashFiles('requirements_test.txt', 'requirements_test_pre_commit.txt') }}-${{
hashFiles('requirements.txt') }}-${{
hashFiles('requirements_all.txt') }}-${{
- hashFiles('homeassistant/package_constraints.txt') }}" >> $GITHUB_OUTPUT
+ hashFiles('homeassistant/package_constraints.txt') }}-${{
+ hashFiles('script/gen_requirements_all.py') }}" >> $GITHUB_OUTPUT
- name: Generate partial pre-commit restore key
id: generate_pre-commit_cache_key
run: >-
@@ -223,7 +224,7 @@ jobs:
- info
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.1.0
@@ -269,7 +270,7 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0
id: python
@@ -309,7 +310,7 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0
id: python
@@ -348,7 +349,7 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0
id: python
@@ -442,7 +443,7 @@ jobs:
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.1.0
@@ -451,8 +452,10 @@ jobs:
check-latest: true
- name: Generate partial uv restore key
id: generate-uv-key
- run: >-
- echo "key=uv-${{ env.UV_CACHE_VERSION }}-${{
+ run: |
+ uv_version=$(cat requirements_test.txt | grep uv | cut -d '=' -f 3)
+ echo "version=${uv_version}" >> $GITHUB_OUTPUT
+ echo "key=uv-${{ env.UV_CACHE_VERSION }}-${uv_version}-${{
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
@@ -472,10 +475,13 @@ jobs:
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
steps.generate-uv-key.outputs.key }}
restore-keys: |
- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-uv-${{ env.UV_CACHE_VERSION }}-${{ env.HA_SHORT_VERSION }}-
+ ${{ runner.os }}-${{ steps.python.outputs.python-version }}-uv-${{
+ env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
+ env.HA_SHORT_VERSION }}-
- name: Install additional OS dependencies
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
+ sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update
sudo apt-get -y install \
bluez \
@@ -497,8 +503,9 @@ jobs:
python --version
pip install "$(grep '^uv' < requirements_test.txt)"
uv pip install -U "pip>=21.3.1" setuptools wheel
- uv pip install -r requirements_all.txt
- uv pip install "$(grep 'python-gammu' < requirements_all.txt | sed -e 's|# python-gammu|python-gammu|g')"
+ uv pip install -r requirements.txt
+ python -m script.gen_requirements_all ci
+ uv pip install -r requirements_all_pytest.txt
uv pip install -r requirements_test.txt
uv pip install -e . --config-settings editable_mode=compat
@@ -513,7 +520,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.1.0
@@ -545,7 +552,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.1.0
@@ -578,7 +585,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.1.0
@@ -622,7 +629,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.1.0
@@ -688,13 +695,14 @@ jobs:
steps:
- name: Install additional OS dependencies
run: |
+ sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update
sudo apt-get -y install \
bluez \
ffmpeg \
libgammu-dev
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.1.0
@@ -715,7 +723,7 @@ jobs:
. venv/bin/activate
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
- name: Upload pytest_buckets
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: pytest_buckets
path: pytest_buckets.txt
@@ -748,13 +756,14 @@ jobs:
steps:
- name: Install additional OS dependencies
run: |
+ sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update
sudo apt-get -y install \
bluez \
ffmpeg \
libgammu-dev
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.1.0
@@ -776,7 +785,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
- uses: actions/download-artifact@v4.1.4
+ uses: actions/download-artifact@v4.1.7
with:
name: pytest_buckets
- name: Compile English translations
@@ -811,14 +820,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -863,13 +872,14 @@ jobs:
steps:
- name: Install additional OS dependencies
run: |
+ sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update
sudo apt-get -y install \
bluez \
ffmpeg \
libmariadb-dev-compat
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.1.0
@@ -933,7 +943,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -941,7 +951,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -985,13 +995,14 @@ jobs:
steps:
- name: Install additional OS dependencies
run: |
+ sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update
sudo apt-get -y install \
bluez \
ffmpeg \
postgresql-server-dev-14
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.1.0
@@ -1056,7 +1067,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1064,7 +1075,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1086,9 +1097,9 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Download all coverage artifacts
- uses: actions/download-artifact@v4.1.4
+ uses: actions/download-artifact@v4.1.7
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1126,13 +1137,14 @@ jobs:
steps:
- name: Install additional OS dependencies
run: |
+ sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update
sudo apt-get -y install \
bluez \
ffmpeg \
libgammu-dev
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.1.0
@@ -1193,14 +1205,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1219,9 +1231,9 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Download all coverage artifacts
- uses: actions/download-artifact@v4.1.4
+ uses: actions/download-artifact@v4.1.7
with:
pattern: coverage-*
- name: Upload coverage to Codecov
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 2b9a2af127f..4f624c582d7 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -21,14 +21,14 @@ jobs:
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Initialize CodeQL
- uses: github/codeql-action/init@v3.25.1
+ uses: github/codeql-action/init@v3.25.3
with:
languages: python
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3.25.1
+ uses: github/codeql-action/analyze@v3.25.3
with:
category: "/language:python"
diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml
index e61eef36f0b..3cf5a7ed089 100644
--- a/.github/workflows/translations.yml
+++ b/.github/workflows/translations.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 7102df0ae4d..4f652b7a0a1 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -14,6 +14,10 @@ on:
- "homeassistant/package_constraints.txt"
- "requirements_all.txt"
- "requirements.txt"
+ - "script/gen_requirements_all.py"
+
+env:
+ DEFAULT_PYTHON: "3.12"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}}
@@ -28,7 +32,22 @@ jobs:
architectures: ${{ steps.info.outputs.architectures }}
steps:
- name: Checkout the repository
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
+
+ - name: Set up Python ${{ env.DEFAULT_PYTHON }}
+ id: python
+ uses: actions/setup-python@v5.1.0
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ check-latest: true
+
+ - name: Create Python virtual environment
+ run: |
+ python -m venv venv
+ . venv/bin/activate
+ python --version
+ pip install "$(grep '^uv' < requirements_test.txt)"
+ uv pip install -r requirements.txt
- name: Get information
id: info
@@ -63,19 +82,30 @@ jobs:
) > .env_file
- name: Upload env_file
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: env_file
path: ./.env_file
overwrite: true
- name: Upload requirements_diff
- uses: actions/upload-artifact@v4.3.1
+ uses: actions/upload-artifact@v4.3.3
with:
name: requirements_diff
path: ./requirements_diff.txt
overwrite: true
+ - name: Generate requirements
+ run: |
+ . venv/bin/activate
+ python -m script.gen_requirements_all ci
+
+ - name: Upload requirements_all_wheels
+ uses: actions/upload-artifact@v4.3.3
+ with:
+ name: requirements_all_wheels
+ path: ./requirements_all_wheels_*.txt
+
core:
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
if: github.repository_owner == 'home-assistant'
@@ -88,15 +118,15 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Download env_file
- uses: actions/download-artifact@v4.1.4
+ uses: actions/download-artifact@v4.1.7
with:
name: env_file
- name: Download requirements_diff
- uses: actions/download-artifact@v4.1.4
+ uses: actions/download-artifact@v4.1.7
with:
name: requirements_diff
@@ -126,42 +156,22 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.4
- name: Download env_file
- uses: actions/download-artifact@v4.1.4
+ uses: actions/download-artifact@v4.1.7
with:
name: env_file
- name: Download requirements_diff
- uses: actions/download-artifact@v4.1.4
+ uses: actions/download-artifact@v4.1.7
with:
name: requirements_diff
- - name: (Un)comment packages
- run: |
- requirement_files="requirements_all.txt requirements_diff.txt"
- for requirement_file in ${requirement_files}; do
- sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file}
- sed -i "s|# evdev|evdev|g" ${requirement_file}
- sed -i "s|# pycups|pycups|g" ${requirement_file}
- sed -i "s|# decora-wifi|decora-wifi|g" ${requirement_file}
- sed -i "s|# python-gammu|python-gammu|g" ${requirement_file}
-
- # Some packages are not buildable on armhf anymore
- if [ "${{ matrix.arch }}" = "armhf" ]; then
-
- # Pandas has issues building on armhf, it is expected they
- # will drop the platform in the near future (they consider it
- # "flimsy" on 386). The following packages depend on pandas,
- # so we comment them out.
- sed -i "s|env-canada|# env-canada|g" ${requirement_file}
- sed -i "s|noaa-coops|# noaa-coops|g" ${requirement_file}
- sed -i "s|pyezviz|# pyezviz|g" ${requirement_file}
- sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file}
- fi
-
- done
+ - name: Download requirements_all_wheels
+ uses: actions/download-artifact@v4.1.7
+ with:
+ name: requirements_all_wheels
- name: Split requirements all
run: |
@@ -169,7 +179,7 @@ jobs:
# This is to prevent the build from running out of memory when
# resolving packages on 32-bits systems (like armhf, armv7).
- split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all.txt requirements_all.txt
+ split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt
- name: Create requirements for cython<3
run: |
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index cd42fecbfa1..40757c09e95 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.3.7
+ rev: v0.4.2
hooks:
- id: ruff
args:
diff --git a/.strict-typing b/.strict-typing
index 5985938885f..584ccc5ee0a 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -235,6 +235,7 @@ homeassistant.components.homeworks.*
homeassistant.components.http.*
homeassistant.components.huawei_lte.*
homeassistant.components.humidifier.*
+homeassistant.components.husqvarna_automower.*
homeassistant.components.hydrawise.*
homeassistant.components.hyperion.*
homeassistant.components.ibeacon.*
diff --git a/CODEOWNERS b/CODEOWNERS
index 3b617f97453..023c0eaa89e 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -127,8 +127,8 @@ build.json @home-assistant/supervisor
/tests/components/aprilaire/ @chamberlain2007
/homeassistant/components/aprs/ @PhilRW
/tests/components/aprs/ @PhilRW
-/homeassistant/components/aranet/ @aschmitz @thecode
-/tests/components/aranet/ @aschmitz @thecode
+/homeassistant/components/aranet/ @aschmitz @thecode @anrijs
+/tests/components/aranet/ @aschmitz @thecode @anrijs
/homeassistant/components/arcam_fmj/ @elupus
/tests/components/arcam_fmj/ @elupus
/homeassistant/components/arris_tg2492lg/ @vanbalken
@@ -398,6 +398,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50
+/homeassistant/components/epic_games_store/ @hacf-fr @Quentame
+/tests/components/epic_games_store/ @hacf-fr @Quentame
/homeassistant/components/epion/ @lhgravendeel
/tests/components/epion/ @lhgravendeel
/homeassistant/components/epson/ @pszafer
@@ -599,6 +601,8 @@ build.json @home-assistant/supervisor
/tests/components/homekit_controller/ @Jc2k @bdraco
/homeassistant/components/homematic/ @pvizeli
/tests/components/homematic/ @pvizeli
+/homeassistant/components/homematicip_cloud/ @hahn-th
+/tests/components/homematicip_cloud/ @hahn-th
/homeassistant/components/homewizard/ @DCSBL
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer
@@ -873,8 +877,8 @@ build.json @home-assistant/supervisor
/tests/components/motioneye/ @dermotduffy
/homeassistant/components/motionmount/ @RJPoelstra
/tests/components/motionmount/ @RJPoelstra
-/homeassistant/components/mqtt/ @emontnemery @jbouwh
-/tests/components/mqtt/ @emontnemery @jbouwh
+/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
+/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
/homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mullvad/ @meichthys
/tests/components/mullvad/ @meichthys
@@ -1284,8 +1288,8 @@ build.json @home-assistant/supervisor
/tests/components/snmp/ @nmaggioni
/homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst
-/homeassistant/components/solaredge/ @frenck
-/tests/components/solaredge/ @frenck
+/homeassistant/components/solaredge/ @frenck @bdraco
+/tests/components/solaredge/ @frenck @bdraco
/homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79
/tests/components/solarlog/ @Ernst79
@@ -1582,8 +1586,8 @@ build.json @home-assistant/supervisor
/tests/components/wiz/ @sbidy
/homeassistant/components/wled/ @frenck
/tests/components/wled/ @frenck
-/homeassistant/components/wolflink/ @adamkrol93
-/tests/components/wolflink/ @adamkrol93
+/homeassistant/components/wolflink/ @adamkrol93 @mtielen
+/tests/components/wolflink/ @adamkrol93 @mtielen
/homeassistant/components/workday/ @fabaff @gjohansson-ST
/tests/components/workday/ @fabaff @gjohansson-ST
/homeassistant/components/worldclock/ @fabaff
diff --git a/Dockerfile b/Dockerfile
index 28b65d6383d..c916a3d2f3c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,7 +12,7 @@ ENV \
ARG QEMU_CPU
# Install uv
-RUN pip3 install uv==0.1.27
+RUN pip3 install uv==0.1.35
WORKDIR /usr/src
diff --git a/Dockerfile.dev b/Dockerfile.dev
index e60456f7b1f..507cc9a7bb2 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -22,6 +22,7 @@ RUN \
libavcodec-dev \
libavdevice-dev \
libavutil-dev \
+ libgammu-dev \
libswscale-dev \
libswresample-dev \
libavfilter-dev \
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index afb364e6d2f..cbc808eb0fa 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -253,6 +253,9 @@ async def async_setup_hass(
runtime_config.log_no_color,
)
+ if runtime_config.debug or hass.loop.get_debug():
+ hass.config.debug = True
+
hass.config.safe_mode = runtime_config.safe_mode
hass.config.skip_pip = runtime_config.skip_pip
hass.config.skip_pip_packages = runtime_config.skip_pip_packages
@@ -316,6 +319,7 @@ async def async_setup_hass(
hass = core.HomeAssistant(old_config.config_dir)
if old_logging:
hass.data[DATA_LOGGING] = old_logging
+ hass.config.debug = old_config.debug
hass.config.skip_pip = old_config.skip_pip
hass.config.skip_pip_packages = old_config.skip_pip_packages
hass.config.internal_url = old_config.internal_url
diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json
index fa651d98efd..24a8180eef8 100644
--- a/homeassistant/components/accuweather/manifest.json
+++ b/homeassistant/components/accuweather/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"quality_scale": "platinum",
- "requirements": ["accuweather==2.1.1"],
+ "requirements": ["accuweather==3.0.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/accuweather/system_health.py b/homeassistant/components/accuweather/system_health.py
index 607a557f333..f47828cb5a3 100644
--- a/homeassistant/components/accuweather/system_health.py
+++ b/homeassistant/components/accuweather/system_health.py
@@ -24,7 +24,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
remaining_requests = list(hass.data[DOMAIN].values())[
0
- ].accuweather.requests_remaining
+ ].coordinator_observation.accuweather.requests_remaining
return {
"can_reach_server": system_health.async_check_can_reach_url(hass, ENDPOINT),
diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py
index fc91d816aca..f0a3dc5be8f 100644
--- a/homeassistant/components/airthings/sensor.py
+++ b/homeassistant/components/airthings/sensor.py
@@ -157,3 +157,11 @@ class AirthingsHeaterEnergySensor(
def native_value(self) -> StateType:
"""Return the value reported by the sensor."""
return self.coordinator.data[self._id].sensors[self.entity_description.key] # type: ignore[no-any-return]
+
+ @property
+ def available(self) -> bool:
+ """Check if device and sensor is available in data."""
+ return (
+ super().available
+ and self.entity_description.key in self.coordinator.data[self._id].sensors
+ )
diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py
index fdf72ccce28..217d5dccc25 100644
--- a/homeassistant/components/alexa/intent.py
+++ b/homeassistant/components/alexa/intent.py
@@ -1,5 +1,6 @@
"""Support for Alexa skill service end point."""
+from collections.abc import Callable, Coroutine
import enum
import logging
from typing import Any
@@ -16,7 +17,9 @@ from .const import DOMAIN, SYN_RESOLUTION_MATCH
_LOGGER = logging.getLogger(__name__)
-HANDLERS = Registry() # type: ignore[var-annotated]
+HANDLERS: Registry[
+ str, Callable[[HomeAssistant, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]]
+] = Registry()
INTENTS_API_ENDPOINT = "/api/alexa"
@@ -129,8 +132,7 @@ async def async_handle_message(
if not (handler := HANDLERS.get(req_type)):
raise UnknownRequest(f"Received unknown request {req_type}")
- response: dict[str, Any] = await handler(hass, message)
- return response
+ return await handler(hass, message)
@HANDLERS.register("SessionEndedRequest")
diff --git a/homeassistant/components/aranet/const.py b/homeassistant/components/aranet/const.py
index 056c627daa8..e038a073fd5 100644
--- a/homeassistant/components/aranet/const.py
+++ b/homeassistant/components/aranet/const.py
@@ -1,3 +1,4 @@
"""Constants for the Aranet integration."""
DOMAIN = "aranet"
+ARANET_MANUFACTURER_NAME = "SAF Tehnika"
diff --git a/homeassistant/components/aranet/icons.json b/homeassistant/components/aranet/icons.json
new file mode 100644
index 00000000000..6d6e9a83b03
--- /dev/null
+++ b/homeassistant/components/aranet/icons.json
@@ -0,0 +1,12 @@
+{
+ "entity": {
+ "sensor": {
+ "radiation_total": {
+ "default": "mdi:radioactive"
+ },
+ "radiation_rate": {
+ "default": "mdi:radioactive"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json
index 152c56e80f3..a1cd80cc3c7 100644
--- a/homeassistant/components/aranet/manifest.json
+++ b/homeassistant/components/aranet/manifest.json
@@ -13,7 +13,7 @@
"connectable": false
}
],
- "codeowners": ["@aschmitz", "@thecode"],
+ "codeowners": ["@aschmitz", "@thecode", "@anrijs"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/aranet",
diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py
index b55fe2bc5ce..4509aa66027 100644
--- a/homeassistant/components/aranet/sensor.py
+++ b/homeassistant/components/aranet/sensor.py
@@ -23,6 +23,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
+ ATTR_MANUFACTURER,
ATTR_NAME,
ATTR_SW_VERSION,
CONCENTRATION_PARTS_PER_MILLION,
@@ -37,7 +38,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from .const import ARANET_MANUFACTURER_NAME, DOMAIN
@dataclass(frozen=True)
@@ -48,6 +49,7 @@ class AranetSensorEntityDescription(SensorEntityDescription):
# Restrict the type to satisfy the type checker and catch attempts
# to use UNDEFINED in the entity descriptions.
name: str | None = None
+ scale: float | int = 1
SENSOR_DESCRIPTIONS = {
@@ -79,6 +81,24 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
+ "radiation_rate": AranetSensorEntityDescription(
+ key="radiation_rate",
+ translation_key="radiation_rate",
+ name="Radiation Dose Rate",
+ native_unit_of_measurement="μSv/h",
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=2,
+ scale=0.001,
+ ),
+ "radiation_total": AranetSensorEntityDescription(
+ key="radiation_total",
+ translation_key="radiation_total",
+ name="Radiation Total Dose",
+ native_unit_of_measurement="mSv",
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=4,
+ scale=0.000001,
+ ),
"battery": AranetSensorEntityDescription(
key="battery",
name="Battery",
@@ -115,6 +135,7 @@ def _sensor_device_info_to_hass(
hass_device_info = DeviceInfo({})
if adv.readings and adv.readings.name:
hass_device_info[ATTR_NAME] = adv.readings.name
+ hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
if adv.manufacturer_data:
hass_device_info[ATTR_SW_VERSION] = str(adv.manufacturer_data.version)
return hass_device_info
@@ -132,6 +153,7 @@ def sensor_update_to_bluetooth_data_update(
val = getattr(adv.readings, key)
if val == -1:
continue
+ val *= desc.scale
data[tag] = val
names[tag] = desc.name
descs[tag] = desc
diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json
index ac8d1907770..1cc695637d4 100644
--- a/homeassistant/components/aranet/strings.json
+++ b/homeassistant/components/aranet/strings.json
@@ -17,7 +17,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "integrations_diabled": "This device doesn't have integrations enabled. Please enable smart home integrations using the app and try again.",
+ "integrations_disabled": "This device doesn't have integrations enabled. Please enable smart home integrations using the app and try again.",
"no_devices_found": "No unconfigured Aranet devices found.",
"outdated_version": "This device is using outdated firmware. Please update it to at least v1.2.0 and try again."
}
diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py
index 7550f860a9b..3e8cdf6fa42 100644
--- a/homeassistant/components/assist_pipeline/websocket_api.py
+++ b/homeassistant/components/assist_pipeline/websocket_api.py
@@ -291,8 +291,11 @@ def websocket_list_runs(
msg["id"],
{
"pipeline_runs": [
- {"pipeline_run_id": id, "timestamp": pipeline_run.timestamp}
- for id, pipeline_run in pipeline_debug.items()
+ {
+ "pipeline_run_id": pipeline_run_id,
+ "timestamp": pipeline_run.timestamp,
+ }
+ for pipeline_run_id, pipeline_run in pipeline_debug.items()
]
},
)
diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py
index 89a2817e236..fa242ac1557 100644
--- a/homeassistant/components/automation/__init__.py
+++ b/homeassistant/components/automation/__init__.py
@@ -707,7 +707,10 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
@callback
def started_action() -> None:
- self.hass.bus.async_fire(
+ # This is always a callback from a coro so there is no
+ # risk of this running in a thread which allows us to use
+ # async_fire_internal
+ self.hass.bus.async_fire_internal(
EVENT_AUTOMATION_TRIGGERED, event_data, context=trigger_context
)
diff --git a/homeassistant/components/automation/logbook.py b/homeassistant/components/automation/logbook.py
index 7b9c8cf5809..33ed586f901 100644
--- a/homeassistant/components/automation/logbook.py
+++ b/homeassistant/components/automation/logbook.py
@@ -1,5 +1,8 @@
"""Describe logbook events."""
+from collections.abc import Callable
+from typing import Any
+
from homeassistant.components.logbook import (
LOGBOOK_ENTRY_CONTEXT_ID,
LOGBOOK_ENTRY_ENTITY_ID,
@@ -16,11 +19,16 @@ from .const import DOMAIN
@callback
-def async_describe_events(hass: HomeAssistant, async_describe_event): # type: ignore[no-untyped-def]
+def async_describe_events(
+ hass: HomeAssistant,
+ async_describe_event: Callable[
+ [str, str, Callable[[LazyEventPartialState], dict[str, Any]]], None
+ ],
+) -> None:
"""Describe logbook events."""
@callback
- def async_describe_logbook_event(event: LazyEventPartialState): # type: ignore[no-untyped-def]
+ def async_describe_logbook_event(event: LazyEventPartialState) -> dict[str, Any]:
"""Describe a logbook event."""
data = event.data
message = "triggered"
diff --git a/homeassistant/components/axis/hub/event_source.py b/homeassistant/components/axis/hub/event_source.py
new file mode 100644
index 00000000000..7f2bfe7c982
--- /dev/null
+++ b/homeassistant/components/axis/hub/event_source.py
@@ -0,0 +1,93 @@
+"""Axis network device abstraction."""
+
+from __future__ import annotations
+
+import axis
+from axis.errors import Unauthorized
+from axis.interfaces.mqtt import mqtt_json_to_event
+from axis.models.mqtt import ClientState
+from axis.stream_manager import Signal, State
+
+from homeassistant.components import mqtt
+from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN
+from homeassistant.components.mqtt.models import ReceiveMessage
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.setup import async_when_setup
+
+
+class AxisEventSource:
+ """Manage connection to event sources from an Axis device."""
+
+ def __init__(
+ self, hass: HomeAssistant, config_entry: ConfigEntry, api: axis.AxisDevice
+ ) -> None:
+ """Initialize the device."""
+ self.hass = hass
+ self.config_entry = config_entry
+ self.api = api
+
+ self.signal_reachable = f"axis_reachable_{config_entry.entry_id}"
+
+ self.available = True
+
+ @callback
+ def setup(self) -> None:
+ """Set up the device events."""
+ self.api.stream.connection_status_callback.append(self._connection_status_cb)
+ self.api.enable_events()
+ self.api.stream.start()
+
+ if self.api.vapix.mqtt.supported:
+ async_when_setup(self.hass, MQTT_DOMAIN, self._async_use_mqtt)
+
+ @callback
+ def teardown(self) -> None:
+ """Tear down connections."""
+ self._disconnect_from_stream()
+
+ @callback
+ def _disconnect_from_stream(self) -> None:
+ """Stop stream."""
+ if self.api.stream.state != State.STOPPED:
+ self.api.stream.connection_status_callback.clear()
+ self.api.stream.stop()
+
+ async def _async_use_mqtt(self, hass: HomeAssistant, component: str) -> None:
+ """Set up to use MQTT."""
+ try:
+ status = await self.api.vapix.mqtt.get_client_status()
+ except Unauthorized:
+ # This means the user has too low privileges
+ return
+
+ if status.status.state == ClientState.ACTIVE:
+ self.config_entry.async_on_unload(
+ await mqtt.async_subscribe(
+ hass, f"{status.config.device_topic_prefix}/#", self._mqtt_message
+ )
+ )
+
+ @callback
+ def _mqtt_message(self, message: ReceiveMessage) -> None:
+ """Receive Axis MQTT message."""
+ self._disconnect_from_stream()
+
+ if message.topic.endswith("event/connection"):
+ return
+
+ event = mqtt_json_to_event(message.payload)
+ self.api.event.handler(event)
+
+ @callback
+ def _connection_status_cb(self, status: Signal) -> None:
+ """Handle signals of device connection status.
+
+ This is called on every RTSP keep-alive message.
+ Only signal state change if state change is true.
+ """
+
+ if self.available != (status == Signal.PLAYING):
+ self.available = not self.available
+ async_dispatcher_send(self.hass, self.signal_reachable)
diff --git a/homeassistant/components/axis/hub/hub.py b/homeassistant/components/axis/hub/hub.py
index 4abd1358417..4e58e3be7c6 100644
--- a/homeassistant/components/axis/hub/hub.py
+++ b/homeassistant/components/axis/hub/hub.py
@@ -5,24 +5,17 @@ from __future__ import annotations
from typing import Any
import axis
-from axis.errors import Unauthorized
-from axis.interfaces.mqtt import mqtt_json_to_event
-from axis.models.mqtt import ClientState
-from axis.stream_manager import Signal, State
-from homeassistant.components import mqtt
-from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN
-from homeassistant.components.mqtt.models import ReceiveMessage
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.setup import async_when_setup
from ..const import ATTR_MANUFACTURER, DOMAIN as AXIS_DOMAIN
from .config import AxisConfig
from .entity_loader import AxisEntityLoader
+from .event_source import AxisEventSource
class AxisHub:
@@ -35,9 +28,9 @@ class AxisHub:
self.hass = hass
self.config = AxisConfig.from_config_entry(config_entry)
self.entity_loader = AxisEntityLoader(self)
+ self.event_source = AxisEventSource(hass, config_entry, api)
self.api = api
- self.available = True
self.fw_version = api.vapix.firmware_version
self.product_type = api.vapix.product_type
self.unique_id = format_mac(api.vapix.serial_number)
@@ -51,32 +44,23 @@ class AxisHub:
hub: AxisHub = hass.data[AXIS_DOMAIN][config_entry.entry_id]
return hub
+ @property
+ def available(self) -> bool:
+ """Connection state to the device."""
+ return self.event_source.available
+
# Signals
@property
def signal_reachable(self) -> str:
"""Device specific event to signal a change in connection status."""
- return f"axis_reachable_{self.config.entry.entry_id}"
+ return self.event_source.signal_reachable
@property
def signal_new_address(self) -> str:
"""Device specific event to signal a change in device address."""
return f"axis_new_address_{self.config.entry.entry_id}"
- # Callbacks
-
- @callback
- def connection_status_callback(self, status: Signal) -> None:
- """Handle signals of device connection status.
-
- This is called on every RTSP keep-alive message.
- Only signal state change if state change is true.
- """
-
- if self.available != (status == Signal.PLAYING):
- self.available = not self.available
- async_dispatcher_send(self.hass, self.signal_reachable)
-
@staticmethod
async def async_new_address_callback(
hass: HomeAssistant, config_entry: ConfigEntry
@@ -89,6 +73,7 @@ class AxisHub:
"""
hub = AxisHub.get_hub(hass, config_entry)
hub.config = AxisConfig.from_config_entry(config_entry)
+ hub.event_source.config_entry = config_entry
hub.api.config.host = hub.config.host
async_dispatcher_send(hass, hub.signal_new_address)
@@ -106,57 +91,19 @@ class AxisHub:
sw_version=self.fw_version,
)
- async def async_use_mqtt(self, hass: HomeAssistant, component: str) -> None:
- """Set up to use MQTT."""
- try:
- status = await self.api.vapix.mqtt.get_client_status()
- except Unauthorized:
- # This means the user has too low privileges
- return
- if status.status.state == ClientState.ACTIVE:
- self.config.entry.async_on_unload(
- await mqtt.async_subscribe(
- hass, f"{status.config.device_topic_prefix}/#", self.mqtt_message
- )
- )
-
- @callback
- def mqtt_message(self, message: ReceiveMessage) -> None:
- """Receive Axis MQTT message."""
- self.disconnect_from_stream()
- if message.topic.endswith("event/connection"):
- return
- event = mqtt_json_to_event(message.payload)
- self.api.event.handler(event)
-
# Setup and teardown methods
@callback
def setup(self) -> None:
"""Set up the device events."""
self.entity_loader.initialize_platforms()
-
- self.api.stream.connection_status_callback.append(
- self.connection_status_callback
- )
- self.api.enable_events()
- self.api.stream.start()
-
- if self.api.vapix.mqtt.supported:
- async_when_setup(self.hass, MQTT_DOMAIN, self.async_use_mqtt)
-
- @callback
- def disconnect_from_stream(self) -> None:
- """Stop stream."""
- if self.api.stream.state != State.STOPPED:
- self.api.stream.connection_status_callback.clear()
- self.api.stream.stop()
+ self.event_source.setup()
async def shutdown(self, event: Event) -> None:
"""Stop the event stream."""
- self.disconnect_from_stream()
+ self.event_source.teardown()
@callback
def teardown(self) -> None:
"""Reset this device to default state."""
- self.disconnect_from_stream()
+ self.event_source.teardown()
diff --git a/homeassistant/components/baf/const.py b/homeassistant/components/baf/const.py
index 9876d7ffec3..4d5020bdf02 100644
--- a/homeassistant/components/baf/const.py
+++ b/homeassistant/components/baf/const.py
@@ -9,7 +9,7 @@ QUERY_INTERVAL = 300
RUN_TIMEOUT = 20
-PRESET_MODE_AUTO = "Auto"
+PRESET_MODE_AUTO = "auto"
SPEED_COUNT = 7
SPEED_RANGE = (1, SPEED_COUNT)
diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py
index 15c6519747d..6c90e2a53cb 100644
--- a/homeassistant/components/baf/fan.py
+++ b/homeassistant/components/baf/fan.py
@@ -48,6 +48,7 @@ class BAFFan(BAFEntity, FanEntity):
_attr_preset_modes = [PRESET_MODE_AUTO]
_attr_speed_count = SPEED_COUNT
_attr_name = None
+ _attr_translation_key = "baf"
@callback
def _async_update_attrs(self) -> None:
diff --git a/homeassistant/components/baf/icons.json b/homeassistant/components/baf/icons.json
new file mode 100644
index 00000000000..c91c4cde86a
--- /dev/null
+++ b/homeassistant/components/baf/icons.json
@@ -0,0 +1,15 @@
+{
+ "entity": {
+ "fan": {
+ "baf": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "auto": "mdi:fan-auto"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/baf/strings.json b/homeassistant/components/baf/strings.json
index 5143b519d27..e2f02a6095e 100644
--- a/homeassistant/components/baf/strings.json
+++ b/homeassistant/components/baf/strings.json
@@ -26,6 +26,17 @@
"name": "Auto comfort"
}
},
+ "fan": {
+ "baf": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]"
+ }
+ }
+ }
+ }
+ },
"number": {
"comfort_min_speed": {
"name": "Auto Comfort Minimum Speed"
diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py
index 2488c2e64f5..07b9d0befe1 100644
--- a/homeassistant/components/bang_olufsen/__init__.py
+++ b/homeassistant/components/bang_olufsen/__init__.py
@@ -4,7 +4,11 @@ from __future__ import annotations
from dataclasses import dataclass
-from aiohttp.client_exceptions import ClientConnectorError
+from aiohttp.client_exceptions import (
+ ClientConnectorError,
+ ClientOSError,
+ ServerTimeoutError,
+)
from mozart_api.exceptions import ApiException
from mozart_api.mozart_client import MozartClient
@@ -44,12 +48,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
model=entry.data[CONF_MODEL],
)
- client = MozartClient(host=entry.data[CONF_HOST], websocket_reconnect=True)
+ client = MozartClient(host=entry.data[CONF_HOST])
- # Check connection and try to initialize it.
+ # Check API and WebSocket connection
try:
- await client.get_battery_state(_request_timeout=3)
- except (ApiException, ClientConnectorError, TimeoutError) as error:
+ await client.check_device_connection(True)
+ except* (
+ ClientConnectorError,
+ ClientOSError,
+ ServerTimeoutError,
+ ApiException,
+ TimeoutError,
+ ) as error:
await client.close_api_client()
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
@@ -61,11 +71,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client,
)
- # Check and start WebSocket connection
- if not await client.connect_notifications(remote_control=True):
- raise ConfigEntryNotReady(
- f"Unable to connect to {entry.title} WebSocket notification channel"
- )
+ # Start WebSocket connection
+ await client.connect_notifications(remote_control=True, reconnect=True)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json
index 3c920a99d7f..f2b31293227 100644
--- a/homeassistant/components/bang_olufsen/manifest.json
+++ b/homeassistant/components/bang_olufsen/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["mozart-api==3.2.1.150.6"],
+ "requirements": ["mozart-api==3.4.1.8.5"],
"zeroconf": ["_bangolufsen._tcp.local."]
}
diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py
index 935c057efc8..9f55790d711 100644
--- a/homeassistant/components/bang_olufsen/media_player.py
+++ b/homeassistant/components/bang_olufsen/media_player.py
@@ -363,7 +363,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
def is_volume_muted(self) -> bool | None:
"""Boolean if volume is currently muted."""
if self._volume.muted and self._volume.muted.muted:
- return self._volume.muted.muted
+ # The any return here is side effect of pydantic v2 compatibility
+ # This will be fixed in the future.
+ return self._volume.muted.muted # type: ignore[no-any-return]
return None
@property
diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py
index 318bb18772a..7461d7b2a2b 100644
--- a/homeassistant/components/blink/camera.py
+++ b/homeassistant/components/blink/camera.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Mapping
-import contextlib
import logging
from typing import Any
@@ -97,7 +96,10 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
await self._camera.async_arm(True)
except TimeoutError as er:
- raise HomeAssistantError("Blink failed to arm camera") from er
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="failed_arm",
+ ) from er
self._camera.motion_enabled = True
await self.coordinator.async_refresh()
@@ -107,7 +109,10 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
try:
await self._camera.async_arm(False)
except TimeoutError as er:
- raise HomeAssistantError("Blink failed to disarm camera") from er
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="failed_disarm",
+ ) from er
self._camera.motion_enabled = False
await self.coordinator.async_refresh()
@@ -124,8 +129,14 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
async def trigger_camera(self) -> None:
"""Trigger camera to take a snapshot."""
- with contextlib.suppress(TimeoutError):
+ try:
await self._camera.snap_picture()
+ except TimeoutError as er:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="failed_snap",
+ ) from er
+
self.async_write_ha_state()
def camera_image(
diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json
index 2260acede1c..2c0be3d972c 100644
--- a/homeassistant/components/blink/strings.json
+++ b/homeassistant/components/blink/strings.json
@@ -106,16 +106,31 @@
},
"exceptions": {
"integration_not_found": {
- "message": "Integration \"{target}\" not found in registry"
+ "message": "Integration \"{target}\" not found in registry."
},
"no_path": {
"message": "Can't write to directory {target}, no access to path!"
},
"cant_write": {
- "message": "Can't write to file"
+ "message": "Can't write to file."
},
"not_loaded": {
- "message": "{target} is not loaded"
+ "message": "{target} is not loaded."
+ },
+ "failed_arm": {
+ "message": "Blink failed to arm camera."
+ },
+ "failed_disarm": {
+ "message": "Blink failed to disarm camera."
+ },
+ "failed_snap": {
+ "message": "Blink failed to snap a picture."
+ },
+ "failed_arm_motion": {
+ "message": "Blink failed to arm camera motion detection."
+ },
+ "failed_disarm_motion": {
+ "message": "Blink failed to disarm camera motion detection."
}
},
"issues": {
diff --git a/homeassistant/components/blink/switch.py b/homeassistant/components/blink/switch.py
index 1bfd257ecbe..ab9b825ded1 100644
--- a/homeassistant/components/blink/switch.py
+++ b/homeassistant/components/blink/switch.py
@@ -75,7 +75,8 @@ class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity):
except TimeoutError as er:
raise HomeAssistantError(
- "Blink failed to arm camera motion detection"
+ translation_domain=DOMAIN,
+ translation_key="failed_arm_motion",
) from er
await self.coordinator.async_refresh()
@@ -87,7 +88,8 @@ class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity):
except TimeoutError as er:
raise HomeAssistantError(
- "Blink failed to dis-arm camera motion detection"
+ translation_domain=DOMAIN,
+ translation_key="failed_disarm_motion",
) from er
await self.coordinator.async_refresh()
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
index cb6f013dbf8..6c63067a1c1 100644
--- a/homeassistant/components/bluesound/media_player.py
+++ b/homeassistant/components/bluesound/media_player.py
@@ -934,7 +934,7 @@ class BluesoundPlayer(MediaPlayerEntity):
selected_source = items[0]
url = f"Play?url={selected_source['url']}&preset_id&image={selected_source['image']}"
- if "is_raw_url" in selected_source and selected_source["is_raw_url"]:
+ if selected_source.get("is_raw_url"):
url = selected_source["url"]
return await self.send_bluesound_command(url)
diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py
index 560fb0663a8..4768d58379a 100644
--- a/homeassistant/components/bluetooth/__init__.py
+++ b/homeassistant/components/bluetooth/__init__.py
@@ -86,6 +86,7 @@ from .manager import HomeAssistantBluetoothManager
from .match import BluetoothCallbackMatcher, IntegrationMatcher
from .models import BluetoothCallback, BluetoothChange
from .storage import BluetoothStorage
+from .util import adapter_title
if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
@@ -332,6 +333,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) from err
adapters = await manager.async_get_bluetooth_adapters()
details = adapters[adapter]
+ if entry.title == address:
+ hass.config_entries.async_update_entry(
+ entry, title=adapter_title(adapter, details)
+ )
slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS
entry.async_on_unload(async_register_scanner(hass, scanner, connection_slots=slots))
await async_update_device(hass, entry, adapter, details)
diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py
index 87038d48151..90d2624fb0f 100644
--- a/homeassistant/components/bluetooth/config_flow.py
+++ b/homeassistant/components/bluetooth/config_flow.py
@@ -12,7 +12,6 @@ from bluetooth_adapters import (
AdapterDetails,
adapter_human_name,
adapter_model,
- adapter_unique_name,
get_adapters,
)
import voluptuous as vol
@@ -28,6 +27,7 @@ from homeassistant.helpers.typing import DiscoveryInfoType
from . import models
from .const import CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, DOMAIN
+from .util import adapter_title
OPTIONS_SCHEMA = vol.Schema(
{
@@ -47,14 +47,6 @@ def adapter_display_info(adapter: str, details: AdapterDetails) -> str:
return f"{name} {manufacturer} {model}"
-def adapter_title(adapter: str, details: AdapterDetails) -> str:
- """Return the adapter title."""
- unique_name = adapter_unique_name(adapter, details[ADAPTER_ADDRESS])
- model = adapter_model(details)
- manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown"
- return f"{manufacturer} {model} ({unique_name})"
-
-
class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Bluetooth."""
diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json
index b41c344bdf2..ed1e11d8ddd 100644
--- a/homeassistant/components/bluetooth/manifest.json
+++ b/homeassistant/components/bluetooth/manifest.json
@@ -16,8 +16,8 @@
"requirements": [
"bleak==0.21.1",
"bleak-retry-connector==3.5.0",
- "bluetooth-adapters==0.18.0",
- "bluetooth-auto-recovery==1.4.1",
+ "bluetooth-adapters==0.19.0",
+ "bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.19.0",
"dbus-fast==2.21.1",
"habluetooth==2.8.0"
diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py
index 0faac9a8613..8c7ad13294a 100644
--- a/homeassistant/components/bluetooth/util.py
+++ b/homeassistant/components/bluetooth/util.py
@@ -2,7 +2,14 @@
from __future__ import annotations
-from bluetooth_adapters import BluetoothAdapters
+from bluetooth_adapters import (
+ ADAPTER_ADDRESS,
+ ADAPTER_MANUFACTURER,
+ ADAPTER_PRODUCT,
+ AdapterDetails,
+ BluetoothAdapters,
+ adapter_unique_name,
+)
from bluetooth_data_tools import monotonic_time_coarse
from homeassistant.core import callback
@@ -69,3 +76,12 @@ def async_load_history_from_system(
connectable_loaded_history[address] = service_info
return all_loaded_history, connectable_loaded_history
+
+
+@callback
+def adapter_title(adapter: str, details: AdapterDetails) -> str:
+ """Return the adapter title."""
+ unique_name = adapter_unique_name(adapter, details[ADAPTER_ADDRESS])
+ model = details.get(ADAPTER_PRODUCT, "Unknown")
+ manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown"
+ return f"{manufacturer} {model} ({unique_name})"
diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py
index 45170a0404f..a12d3057258 100644
--- a/homeassistant/components/bond/config_flow.py
+++ b/homeassistant/components/bond/config_flow.py
@@ -113,7 +113,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
):
updates[CONF_ACCESS_TOKEN] = token
return self.async_update_reload_and_abort(
- entry, data={**entry.data, **updates}, reason="already_configured"
+ entry,
+ data={**entry.data, **updates},
+ reason="already_configured",
+ reload_even_if_entry_is_unchanged=False,
)
self._discovered = {CONF_HOST: host, CONF_NAME: bond_id}
diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py
index 64a6ec67f85..911c08a835d 100644
--- a/homeassistant/components/bring/const.py
+++ b/homeassistant/components/bring/const.py
@@ -1,3 +1,11 @@
"""Constants for the Bring! integration."""
+from typing import Final
+
DOMAIN = "bring"
+
+ATTR_SENDER: Final = "sender"
+ATTR_ITEM_NAME: Final = "item"
+ATTR_NOTIFICATION_TYPE: Final = "message"
+
+SERVICE_PUSH_NOTIFICATION = "send_message"
diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json
index a757b20a4cc..1c6c3bdeca0 100644
--- a/homeassistant/components/bring/icons.json
+++ b/homeassistant/components/bring/icons.json
@@ -5,5 +5,8 @@
"default": "mdi:cart"
}
}
+ },
+ "services": {
+ "send_message": "mdi:cellphone-message"
}
}
diff --git a/homeassistant/components/bring/services.yaml b/homeassistant/components/bring/services.yaml
new file mode 100644
index 00000000000..98d5c68de13
--- /dev/null
+++ b/homeassistant/components/bring/services.yaml
@@ -0,0 +1,23 @@
+send_message:
+ target:
+ entity:
+ domain: todo
+ integration: bring
+ fields:
+ message:
+ example: urgent_message
+ required: true
+ default: "going_shopping"
+ selector:
+ select:
+ translation_key: "notification_type_selector"
+ options:
+ - "going_shopping"
+ - "changed_list"
+ - "shopping_done"
+ - "urgent_message"
+ item:
+ example: Cilantro
+ required: false
+ selector:
+ text:
diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json
index 6d61034bea8..e6df885cbbc 100644
--- a/homeassistant/components/bring/strings.json
+++ b/homeassistant/components/bring/strings.json
@@ -38,6 +38,42 @@
},
"setup_authentication_exception": {
"message": "Authentication failed for {email}, check your email and password"
+ },
+ "notify_missing_argument_item": {
+ "message": "Failed to call service {service}. 'URGENT_MESSAGE' requires a value @ data['item']. Got None"
+ },
+ "notify_request_failed": {
+ "message": "Failed to send push notification for bring due to a connection error, try again later"
+ }
+ },
+ "services": {
+ "send_message": {
+ "name": "[%key:component::notify::services::notify::name%]",
+ "description": "Send a mobile push notification to members of a shared Bring! list.",
+ "fields": {
+ "entity_id": {
+ "name": "List",
+ "description": "Bring! list whose members (except sender) will be notified."
+ },
+ "message": {
+ "name": "Notification type",
+ "description": "Type of push notification to send to list members."
+ },
+ "item": {
+ "name": "Item (Required if message type `Breaking news` selected)",
+ "description": "Item name to include in a breaking news message e.g. `Breaking news - Please get cilantro!`"
+ }
+ }
+ }
+ },
+ "selector": {
+ "notification_type_selector": {
+ "options": {
+ "going_shopping": "I'm going shopping! - Last chance for adjustments",
+ "changed_list": "List changed - Check it out",
+ "shopping_done": "Shopping done - you can relax",
+ "urgent_message": "Breaking news - Please get `item`!"
+ }
}
}
}
diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py
index e631dc32951..5eabcc01553 100644
--- a/homeassistant/components/bring/todo.py
+++ b/homeassistant/components/bring/todo.py
@@ -6,7 +6,8 @@ from typing import TYPE_CHECKING
import uuid
from bring_api.exceptions import BringRequestException
-from bring_api.types import BringItem, BringItemOperation
+from bring_api.types import BringItem, BringItemOperation, BringNotificationType
+import voluptuous as vol
from homeassistant.components.todo import (
TodoItem,
@@ -16,11 +17,18 @@ from homeassistant.components.todo import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers import config_validation as cv, entity_platform
+from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
+from .const import (
+ ATTR_ITEM_NAME,
+ ATTR_NOTIFICATION_TYPE,
+ DOMAIN,
+ SERVICE_PUSH_NOTIFICATION,
+)
from .coordinator import BringData, BringDataUpdateCoordinator
@@ -46,6 +54,21 @@ async def async_setup_entry(
for bring_list in coordinator.data.values()
)
+ platform = entity_platform.async_get_current_platform()
+
+ platform.async_register_entity_service(
+ SERVICE_PUSH_NOTIFICATION,
+ make_entity_service_schema(
+ {
+ vol.Required(ATTR_NOTIFICATION_TYPE): vol.All(
+ vol.Upper, cv.enum(BringNotificationType)
+ ),
+ vol.Optional(ATTR_ITEM_NAME): cv.string,
+ }
+ ),
+ "async_send_message",
+ )
+
class BringTodoListEntity(
CoordinatorEntity[BringDataUpdateCoordinator], TodoListEntity
@@ -231,3 +254,26 @@ class BringTodoListEntity(
) from e
await self.coordinator.async_refresh()
+
+ async def async_send_message(
+ self,
+ message: BringNotificationType,
+ item: str | None = None,
+ ) -> None:
+ """Send a push notification to members of a shared bring list."""
+
+ try:
+ await self.coordinator.bring.notify(self._list_uuid, message, item or None)
+ except BringRequestException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="notify_request_failed",
+ ) from e
+ except ValueError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="notify_missing_argument_item",
+ translation_placeholders={
+ "service": f"{DOMAIN}.{SERVICE_PUSH_NOTIFICATION}",
+ },
+ ) from e
diff --git a/homeassistant/components/circuit/__init__.py b/homeassistant/components/circuit/__init__.py
index f71babad3d5..7e7d0eda76e 100644
--- a/homeassistant/components/circuit/__init__.py
+++ b/homeassistant/components/circuit/__init__.py
@@ -5,6 +5,7 @@ import voluptuous as vol
from homeassistant.const import CONF_NAME, CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
+import homeassistant.helpers.issue_registry as ir
from homeassistant.helpers.typing import ConfigType
DOMAIN = "circuit"
@@ -26,6 +27,17 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Unify Circuit component."""
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "service_removal",
+ breaks_in_ha_version="2024.7.0",
+ is_fixable=False,
+ is_persistent=True,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="service_removal",
+ translation_placeholders={"integration": "Unify Circuit", "domain": DOMAIN},
+ )
webhooks = config[DOMAIN][CONF_WEBHOOK]
for webhook_conf in webhooks:
diff --git a/homeassistant/components/circuit/strings.json b/homeassistant/components/circuit/strings.json
new file mode 100644
index 00000000000..b9cb852d5b9
--- /dev/null
+++ b/homeassistant/components/circuit/strings.json
@@ -0,0 +1,8 @@
+{
+ "issues": {
+ "service_removal": {
+ "title": "The {integration} integration is being removed",
+ "description": "The {integration} integration will be removed, as the service is no longer maintained.\n\n\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
+ }
+ }
+}
diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py
index 80f9d9f9368..2552fe4bf5c 100644
--- a/homeassistant/components/cloud/__init__.py
+++ b/homeassistant/components/cloud/__init__.py
@@ -7,11 +7,14 @@ from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from enum import Enum
from typing import cast
+from urllib.parse import quote_plus, urljoin
from hass_nabucasa import Cloud
import voluptuous as vol
-from homeassistant.components import alexa, google_assistant
+from homeassistant.components import alexa, google_assistant, http
+from homeassistant.components.auth import STRICT_CONNECTION_URL
+from homeassistant.components.http.auth import async_sign_path
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
CONF_DESCRIPTION,
@@ -21,8 +24,21 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Platform,
)
-from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.core import (
+ Event,
+ HassJob,
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+ callback,
+)
+from homeassistant.exceptions import (
+ HomeAssistantError,
+ ServiceValidationError,
+ Unauthorized,
+ UnknownUser,
+)
from homeassistant.helpers import config_validation as cv, entityfilter
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.discovery import async_load_platform
@@ -31,6 +47,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -265,18 +282,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
_remote_handle_prefs_updated(cloud)
-
- async def _service_handler(service: ServiceCall) -> None:
- """Handle service for cloud."""
- if service.service == SERVICE_REMOTE_CONNECT:
- await prefs.async_update(remote_enabled=True)
- elif service.service == SERVICE_REMOTE_DISCONNECT:
- await prefs.async_update(remote_enabled=False)
-
- async_register_admin_service(hass, DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler)
- async_register_admin_service(
- hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler
- )
+ _setup_services(hass, prefs)
async def async_startup_repairs(_: datetime) -> None:
"""Create repair issues after startup."""
@@ -395,3 +401,67 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+@callback
+def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None:
+ """Set up services for cloud component."""
+
+ async def _service_handler(service: ServiceCall) -> None:
+ """Handle service for cloud."""
+ if service.service == SERVICE_REMOTE_CONNECT:
+ await prefs.async_update(remote_enabled=True)
+ elif service.service == SERVICE_REMOTE_DISCONNECT:
+ await prefs.async_update(remote_enabled=False)
+
+ async_register_admin_service(hass, DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler)
+ async_register_admin_service(
+ hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler
+ )
+
+ async def create_temporary_strict_connection_url(
+ call: ServiceCall,
+ ) -> ServiceResponse:
+ """Create a strict connection url and return it."""
+ # Copied form homeassistant/helpers/service.py#_async_admin_handler
+ # as the helper supports no responses yet
+ if call.context.user_id:
+ user = await hass.auth.async_get_user(call.context.user_id)
+ if user is None:
+ raise UnknownUser(context=call.context)
+ if not user.is_admin:
+ raise Unauthorized(context=call.context)
+
+ if prefs.strict_connection is http.const.StrictConnectionMode.DISABLED:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="strict_connection_not_enabled",
+ )
+
+ try:
+ url = get_url(hass, require_cloud=True)
+ except NoURLAvailableError as ex:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_url_available",
+ ) from ex
+
+ path = async_sign_path(
+ hass,
+ STRICT_CONNECTION_URL,
+ timedelta(hours=1),
+ use_content_user=True,
+ )
+ url = urljoin(url, path)
+
+ return {
+ "url": f"https://login.home-assistant.io?u={quote_plus(url)}",
+ "direct_url": url,
+ }
+
+ hass.services.async_register(
+ DOMAIN,
+ "create_temporary_strict_connection_url",
+ create_temporary_strict_connection_url,
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py
index 01c8de77156..c4d1c1dec60 100644
--- a/homeassistant/components/cloud/client.py
+++ b/homeassistant/components/cloud/client.py
@@ -250,6 +250,7 @@ class CloudClient(Interface):
"enabled": self._prefs.remote_enabled,
"instance_domain": self.cloud.remote.instance_domain,
"alias": self.cloud.remote.alias,
+ "strict_connection": self._prefs.strict_connection,
},
"version": HA_VERSION,
"instance_id": self.prefs.instance_id,
diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py
index 2c58dd57340..8b68eefc443 100644
--- a/homeassistant/components/cloud/const.py
+++ b/homeassistant/components/cloud/const.py
@@ -33,6 +33,7 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
PREF_GOOGLE_CONNECTED = "google_connected"
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable"
+PREF_STRICT_CONNECTION = "strict_connection"
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural")
DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = True
diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index b577e9de0d4..29185191a20 100644
--- a/homeassistant/components/cloud/http_api.py
+++ b/homeassistant/components/cloud/http_api.py
@@ -19,7 +19,7 @@ from hass_nabucasa.const import STATE_DISCONNECTED
from hass_nabucasa.voice import TTS_VOICES
import voluptuous as vol
-from homeassistant.components import websocket_api
+from homeassistant.components import http, websocket_api
from homeassistant.components.alexa import (
entities as alexa_entities,
errors as alexa_errors,
@@ -46,6 +46,7 @@ from .const import (
PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_REMOTE_ALLOW_REMOTE_ENABLE,
+ PREF_STRICT_CONNECTION,
PREF_TTS_DEFAULT_VOICE,
REQUEST_TIMEOUT,
)
@@ -452,6 +453,9 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]:
vol.Coerce(tuple), validate_language_voice
),
vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
+ vol.Optional(PREF_STRICT_CONNECTION): vol.Coerce(
+ http.const.StrictConnectionMode
+ ),
}
)
@websocket_api.async_response
diff --git a/homeassistant/components/cloud/icons.json b/homeassistant/components/cloud/icons.json
index 06ee7eb2f19..1a8593388b4 100644
--- a/homeassistant/components/cloud/icons.json
+++ b/homeassistant/components/cloud/icons.json
@@ -1,5 +1,6 @@
{
"services": {
+ "create_temporary_strict_connection_url": "mdi:login-variant",
"remote_connect": "mdi:cloud",
"remote_disconnect": "mdi:cloud-off"
}
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index 49a3fc0bf5c..0d2ee546ad8 100644
--- a/homeassistant/components/cloud/manifest.json
+++ b/homeassistant/components/cloud/manifest.json
@@ -3,7 +3,7 @@
"name": "Home Assistant Cloud",
"after_dependencies": ["assist_pipeline", "google_assistant", "alexa"],
"codeowners": ["@home-assistant/cloud"],
- "dependencies": ["http", "repairs", "webhook"],
+ "dependencies": ["auth", "http", "repairs", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/cloud",
"integration_type": "system",
"iot_class": "cloud_push",
diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py
index af4e68194d6..9fce615128b 100644
--- a/homeassistant/components/cloud/prefs.py
+++ b/homeassistant/components/cloud/prefs.py
@@ -10,7 +10,7 @@ from hass_nabucasa.voice import MAP_VOICE
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User
-from homeassistant.components import webhook
+from homeassistant.components import http, webhook
from homeassistant.components.google_assistant.http import (
async_get_users as async_get_google_assistant_users,
)
@@ -44,6 +44,7 @@ from .const import (
PREF_INSTANCE_ID,
PREF_REMOTE_ALLOW_REMOTE_ENABLE,
PREF_REMOTE_DOMAIN,
+ PREF_STRICT_CONNECTION,
PREF_TTS_DEFAULT_VOICE,
PREF_USERNAME,
)
@@ -176,6 +177,7 @@ class CloudPreferences:
google_settings_version: int | UndefinedType = UNDEFINED,
google_connected: bool | UndefinedType = UNDEFINED,
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
+ strict_connection: http.const.StrictConnectionMode | UndefinedType = UNDEFINED,
) -> None:
"""Update user preferences."""
prefs = {**self._prefs}
@@ -195,6 +197,7 @@ class CloudPreferences:
(PREF_REMOTE_DOMAIN, remote_domain),
(PREF_GOOGLE_CONNECTED, google_connected),
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
+ (PREF_STRICT_CONNECTION, strict_connection),
):
if value is not UNDEFINED:
prefs[key] = value
@@ -242,6 +245,7 @@ class CloudPreferences:
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
+ PREF_STRICT_CONNECTION: self.strict_connection,
}
@property
@@ -358,6 +362,17 @@ class CloudPreferences:
"""
return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return]
+ @property
+ def strict_connection(self) -> http.const.StrictConnectionMode:
+ """Return the strict connection mode."""
+ mode = self._prefs.get(
+ PREF_STRICT_CONNECTION, http.const.StrictConnectionMode.DISABLED
+ )
+
+ if not isinstance(mode, http.const.StrictConnectionMode):
+ mode = http.const.StrictConnectionMode(mode)
+ return mode # type: ignore[no-any-return]
+
async def get_cloud_user(self) -> str:
"""Return ID of Home Assistant Cloud system user."""
user = await self._load_cloud_user()
@@ -415,4 +430,5 @@ class CloudPreferences:
PREF_REMOTE_DOMAIN: None,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: True,
PREF_USERNAME: username,
+ PREF_STRICT_CONNECTION: http.const.StrictConnectionMode.DISABLED,
}
diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json
index 16a82a27c1a..1fec87235da 100644
--- a/homeassistant/components/cloud/strings.json
+++ b/homeassistant/components/cloud/strings.json
@@ -5,6 +5,14 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
},
+ "exceptions": {
+ "strict_connection_not_enabled": {
+ "message": "Strict connection is not enabled for cloud requests"
+ },
+ "no_url_available": {
+ "message": "No cloud URL available.\nPlease mark sure you have a working Remote UI."
+ }
+ },
"system_health": {
"info": {
"can_reach_cert_server": "Reach Certificate Server",
@@ -73,6 +81,10 @@
}
},
"services": {
+ "create_temporary_strict_connection_url": {
+ "name": "Create a temporary strict connection URL",
+ "description": "Create a temporary strict connection URL, which can be used to login on another device."
+ },
"remote_connect": {
"name": "Remote connect",
"description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud."
diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py
new file mode 100644
index 00000000000..3e055851fff
--- /dev/null
+++ b/homeassistant/components/cloud/util.py
@@ -0,0 +1,15 @@
+"""Cloud util functions."""
+
+from hass_nabucasa import Cloud
+
+from homeassistant.components import http
+from homeassistant.core import HomeAssistant
+
+from .client import CloudClient
+from .const import DOMAIN
+
+
+def get_strict_connection_mode(hass: HomeAssistant) -> http.const.StrictConnectionMode:
+ """Get the strict connection mode."""
+ cloud: Cloud[CloudClient] = hass.data[DOMAIN]
+ return cloud.client.prefs.strict_connection
diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json
index d93ec349bba..b9264d16f69 100644
--- a/homeassistant/components/comelit/manifest.json
+++ b/homeassistant/components/comelit/manifest.json
@@ -4,7 +4,9 @@
"codeowners": ["@chemelli74"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/comelit",
+ "integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
+ "quality_scale": "silver",
"requirements": ["aiocomelit==0.9.0"]
}
diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py
index b8d195fcb05..86a13de1ac8 100644
--- a/homeassistant/components/control4/__init__.py
+++ b/homeassistant/components/control4/__init__.py
@@ -30,6 +30,7 @@ from homeassistant.helpers.update_coordinator import (
)
from .const import (
+ API_RETRY_TIMES,
CONF_ACCOUNT,
CONF_CONFIG_LISTENER,
CONF_CONTROLLER_UNIQUE_ID,
@@ -47,6 +48,17 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER]
+async def call_c4_api_retry(func, *func_args):
+ """Call C4 API function and retry on failure."""
+ for i in range(API_RETRY_TIMES):
+ try:
+ return await func(*func_args)
+ except client_exceptions.ClientError as exception:
+ _LOGGER.error("Error connecting to Control4 account API: %s", exception)
+ if i == API_RETRY_TIMES - 1:
+ raise ConfigEntryNotReady(exception) from exception
+
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Control4 from a config entry."""
hass.data.setdefault(DOMAIN, {})
@@ -74,18 +86,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID]
entry_data[CONF_CONTROLLER_UNIQUE_ID] = controller_unique_id
- director_token_dict = await account.getDirectorBearerToken(controller_unique_id)
- director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
+ director_token_dict = await call_c4_api_retry(
+ account.getDirectorBearerToken, controller_unique_id
+ )
+ director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
director = C4Director(
config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session
)
entry_data[CONF_DIRECTOR] = director
- # Add Control4 controller to device registry
- controller_href = (await account.getAccountControllers())["href"]
- entry_data[CONF_DIRECTOR_SW_VERSION] = await account.getControllerOSVersion(
- controller_href
+ controller_href = (await call_c4_api_retry(account.getAccountControllers))["href"]
+ entry_data[CONF_DIRECTOR_SW_VERSION] = await call_c4_api_retry(
+ account.getControllerOSVersion, controller_href
)
_, model, mac_address = controller_unique_id.split("_", 3)
diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py
index f8d939e1ac5..57074c00108 100644
--- a/homeassistant/components/control4/const.py
+++ b/homeassistant/components/control4/const.py
@@ -5,6 +5,8 @@ DOMAIN = "control4"
DEFAULT_SCAN_INTERVAL = 5
MIN_SCAN_INTERVAL = 1
+API_RETRY_TIMES = 5
+
CONF_ACCOUNT = "account"
CONF_DIRECTOR = "director"
CONF_DIRECTOR_SW_VERSION = "director_sw_version"
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
index 8ee27986bb8..82e2adca680 100644
--- a/homeassistant/components/conversation/manifest.json
+++ b/homeassistant/components/conversation/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.4.3"]
+ "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.4.24"]
}
diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json
index 0d77b997e82..b8abd0a9919 100644
--- a/homeassistant/components/dhcp/manifest.json
+++ b/homeassistant/components/dhcp/manifest.json
@@ -15,7 +15,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.0.0",
- "aiodiscover==2.0.0",
+ "aiodiscover==2.1.0",
"cached_ipaddress==0.3.0"
]
}
diff --git a/homeassistant/components/drop_connect/manifest.json b/homeassistant/components/drop_connect/manifest.json
index 5df34fce561..ed34767d6e0 100644
--- a/homeassistant/components/drop_connect/manifest.json
+++ b/homeassistant/components/drop_connect/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/drop_connect",
"iot_class": "local_push",
"mqtt": ["drop_connect/discovery/#"],
- "requirements": ["dropmqttapi==1.0.2"]
+ "requirements": ["dropmqttapi==1.0.3"]
}
diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py
index 275d47d15ca..9cf73a90a73 100644
--- a/homeassistant/components/dwd_weather_warnings/__init__.py
+++ b/homeassistant/components/dwd_weather_warnings/__init__.py
@@ -2,23 +2,16 @@
from __future__ import annotations
-from dwdwfsapi import DwdWeatherWarningsAPI
-
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import CONF_REGION_IDENTIFIER, DOMAIN, PLATFORMS
+from .const import DOMAIN, PLATFORMS
from .coordinator import DwdWeatherWarningsCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
- region_identifier: str = entry.data[CONF_REGION_IDENTIFIER]
-
- # Initialize the API and coordinator.
- api = await hass.async_add_executor_job(DwdWeatherWarningsAPI, region_identifier)
- coordinator = DwdWeatherWarningsCoordinator(hass, api)
-
+ coordinator = DwdWeatherWarningsCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py
index 5076dbae187..f148f4e05ac 100644
--- a/homeassistant/components/dwd_weather_warnings/config_flow.py
+++ b/homeassistant/components/dwd_weather_warnings/config_flow.py
@@ -8,9 +8,15 @@ from dwdwfsapi import DwdWeatherWarningsAPI
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
-from .const import CONF_REGION_IDENTIFIER, DOMAIN
+from .const import CONF_REGION_DEVICE_TRACKER, CONF_REGION_IDENTIFIER, DOMAIN
+from .exceptions import EntityNotFoundError
+from .util import get_position_data
+
+EXCLUSIVE_OPTIONS = (CONF_REGION_IDENTIFIER, CONF_REGION_DEVICE_TRACKER)
class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -25,27 +31,70 @@ class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict = {}
if user_input is not None:
- region_identifier = user_input[CONF_REGION_IDENTIFIER]
+ # Check, if either CONF_REGION_IDENTIFIER or CONF_GPS_TRACKER has been set.
+ if all(k not in user_input for k in EXCLUSIVE_OPTIONS):
+ errors["base"] = "no_identifier"
+ elif all(k in user_input for k in EXCLUSIVE_OPTIONS):
+ errors["base"] = "ambiguous_identifier"
+ elif CONF_REGION_IDENTIFIER in user_input:
+ # Validate region identifier using the API
+ identifier = user_input[CONF_REGION_IDENTIFIER]
- # Validate region identifier using the API
- if not await self.hass.async_add_executor_job(
- DwdWeatherWarningsAPI, region_identifier
- ):
- errors["base"] = "invalid_identifier"
+ if not await self.hass.async_add_executor_job(
+ DwdWeatherWarningsAPI, identifier
+ ):
+ errors["base"] = "invalid_identifier"
- if not errors:
- # Set the unique ID for this config entry.
- await self.async_set_unique_id(region_identifier)
- self._abort_if_unique_id_configured()
+ if not errors:
+ # Set the unique ID for this config entry.
+ await self.async_set_unique_id(identifier)
+ self._abort_if_unique_id_configured()
- return self.async_create_entry(title=region_identifier, data=user_input)
+ return self.async_create_entry(title=identifier, data=user_input)
+ else: # CONF_REGION_DEVICE_TRACKER
+ device_tracker = user_input[CONF_REGION_DEVICE_TRACKER]
+ registry = er.async_get(self.hass)
+ entity_entry = registry.async_get(device_tracker)
+
+ if entity_entry is None:
+ errors["base"] = "entity_not_found"
+ else:
+ try:
+ position = get_position_data(self.hass, entity_entry.id)
+ except EntityNotFoundError:
+ errors["base"] = "entity_not_found"
+ except AttributeError:
+ errors["base"] = "attribute_not_found"
+ else:
+ # Validate position using the API
+ if not await self.hass.async_add_executor_job(
+ DwdWeatherWarningsAPI, position
+ ):
+ errors["base"] = "invalid_identifier"
+
+ # Position is valid here, because the API call was successful.
+ if not errors and position is not None and entity_entry is not None:
+ # Set the unique ID for this config entry.
+ await self.async_set_unique_id(entity_entry.id)
+ self._abort_if_unique_id_configured()
+
+ # Replace entity ID with registry ID for more stability.
+ user_input[CONF_REGION_DEVICE_TRACKER] = entity_entry.id
+
+ return self.async_create_entry(
+ title=device_tracker.removeprefix("device_tracker."),
+ data=user_input,
+ )
return self.async_show_form(
step_id="user",
errors=errors,
data_schema=vol.Schema(
{
- vol.Required(CONF_REGION_IDENTIFIER): cv.string,
+ vol.Optional(CONF_REGION_IDENTIFIER): cv.string,
+ vol.Optional(CONF_REGION_DEVICE_TRACKER): EntitySelector(
+ EntitySelectorConfig(domain="device_tracker")
+ ),
}
),
)
diff --git a/homeassistant/components/dwd_weather_warnings/const.py b/homeassistant/components/dwd_weather_warnings/const.py
index 75969dee119..4f0a6767660 100644
--- a/homeassistant/components/dwd_weather_warnings/const.py
+++ b/homeassistant/components/dwd_weather_warnings/const.py
@@ -14,6 +14,7 @@ DOMAIN: Final = "dwd_weather_warnings"
CONF_REGION_NAME: Final = "region_name"
CONF_REGION_IDENTIFIER: Final = "region_identifier"
+CONF_REGION_DEVICE_TRACKER: Final = "region_device_tracker"
ATTR_REGION_NAME: Final = "region_name"
ATTR_REGION_ID: Final = "region_id"
diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py
index a1232697130..465a7c09750 100644
--- a/homeassistant/components/dwd_weather_warnings/coordinator.py
+++ b/homeassistant/components/dwd_weather_warnings/coordinator.py
@@ -4,23 +4,79 @@ from __future__ import annotations
from dwdwfsapi import DwdWeatherWarningsAPI
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util import location
-from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
+from .const import (
+ CONF_REGION_DEVICE_TRACKER,
+ CONF_REGION_IDENTIFIER,
+ DEFAULT_SCAN_INTERVAL,
+ DOMAIN,
+ LOGGER,
+)
+from .exceptions import EntityNotFoundError
+from .util import get_position_data
class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]):
"""Custom coordinator for the dwd_weather_warnings integration."""
- def __init__(self, hass: HomeAssistant, api: DwdWeatherWarningsAPI) -> None:
+ config_entry: ConfigEntry
+ api: DwdWeatherWarningsAPI
+
+ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize the dwd_weather_warnings coordinator."""
super().__init__(
hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL
)
- self.api = api
+ self._device_tracker = None
+ self._previous_position = None
+
+ async def async_config_entry_first_refresh(self) -> None:
+ """Perform first refresh."""
+ if region_identifier := self.config_entry.data.get(CONF_REGION_IDENTIFIER):
+ self.api = await self.hass.async_add_executor_job(
+ DwdWeatherWarningsAPI, region_identifier
+ )
+ else:
+ self._device_tracker = self.config_entry.data.get(
+ CONF_REGION_DEVICE_TRACKER
+ )
+
+ await super().async_config_entry_first_refresh()
async def _async_update_data(self) -> None:
"""Get the latest data from the DWD Weather Warnings API."""
- await self.hass.async_add_executor_job(self.api.update)
+ if self._device_tracker:
+ try:
+ position = get_position_data(self.hass, self._device_tracker)
+ except (EntityNotFoundError, AttributeError) as err:
+ raise UpdateFailed(f"Error fetching position: {repr(err)}") from err
+
+ distance = None
+ if self._previous_position is not None:
+ distance = location.distance(
+ self._previous_position[0],
+ self._previous_position[1],
+ position[0],
+ position[1],
+ )
+
+ if distance is None or distance > 50:
+ # Only create a new object on the first update
+ # or when the distance to the previous position
+ # changes by more than 50 meters (to take GPS
+ # inaccuracy into account).
+ self.api = await self.hass.async_add_executor_job(
+ DwdWeatherWarningsAPI, position
+ )
+ else:
+ # Otherwise update the API to check for new warnings.
+ await self.hass.async_add_executor_job(self.api.update)
+
+ self._previous_position = position
+ else:
+ await self.hass.async_add_executor_job(self.api.update)
diff --git a/homeassistant/components/dwd_weather_warnings/exceptions.py b/homeassistant/components/dwd_weather_warnings/exceptions.py
new file mode 100644
index 00000000000..cd61cfa6bae
--- /dev/null
+++ b/homeassistant/components/dwd_weather_warnings/exceptions.py
@@ -0,0 +1,7 @@
+"""Exceptions for the dwd_weather_warnings integration."""
+
+from homeassistant.exceptions import HomeAssistantError
+
+
+class EntityNotFoundError(HomeAssistantError):
+ """When a referenced entity was not found."""
diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py
index d3e3b4a3772..d62c0f4f192 100644
--- a/homeassistant/components/dwd_weather_warnings/sensor.py
+++ b/homeassistant/components/dwd_weather_warnings/sensor.py
@@ -11,6 +11,8 @@ Wetterwarnungen (Stufe 1)
from __future__ import annotations
+from typing import Any
+
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -93,29 +95,27 @@ class DwdWeatherWarningsSensor(
entry_type=DeviceEntryType.SERVICE,
)
- self.api = coordinator.api
-
@property
- def native_value(self):
+ def native_value(self) -> int | None:
"""Return the state of the sensor."""
if self.entity_description.key == CURRENT_WARNING_SENSOR:
- return self.api.current_warning_level
+ return self.coordinator.api.current_warning_level
- return self.api.expected_warning_level
+ return self.coordinator.api.expected_warning_level
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the sensor."""
data = {
- ATTR_REGION_NAME: self.api.warncell_name,
- ATTR_REGION_ID: self.api.warncell_id,
- ATTR_LAST_UPDATE: self.api.last_update,
+ ATTR_REGION_NAME: self.coordinator.api.warncell_name,
+ ATTR_REGION_ID: self.coordinator.api.warncell_id,
+ ATTR_LAST_UPDATE: self.coordinator.api.last_update,
}
if self.entity_description.key == CURRENT_WARNING_SENSOR:
- searched_warnings = self.api.current_warnings
+ searched_warnings = self.coordinator.api.current_warnings
else:
- searched_warnings = self.api.expected_warnings
+ searched_warnings = self.coordinator.api.expected_warnings
data[ATTR_WARNING_COUNT] = len(searched_warnings)
@@ -142,4 +142,4 @@ class DwdWeatherWarningsSensor(
@property
def available(self) -> bool:
"""Could the device be accessed during the last update call."""
- return self.api.data_valid
+ return self.coordinator.api.data_valid
diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json
index aa460dcc6d5..3f421d338a7 100644
--- a/homeassistant/components/dwd_weather_warnings/strings.json
+++ b/homeassistant/components/dwd_weather_warnings/strings.json
@@ -2,17 +2,22 @@
"config": {
"step": {
"user": {
- "description": "To identify the desired region, the warncell ID / name is required.",
+ "description": "To identify the desired region, either the warncell ID / name or device tracker is required. The provided device tracker has to contain the attributes 'latitude' and 'longitude'.",
"data": {
- "region_identifier": "Warncell ID or name"
+ "region_identifier": "Warncell ID or name",
+ "region_device_tracker": "Device tracker entity"
}
}
},
"error": {
- "invalid_identifier": "The specified region identifier is invalid."
+ "no_identifier": "Either the region identifier or device tracker is required.",
+ "ambiguous_identifier": "The region identifier and device tracker can not be specified together.",
+ "invalid_identifier": "The specified region identifier / device tracker is invalid.",
+ "entity_not_found": "The specified device tracker entity was not found.",
+ "attribute_not_found": "The required `latitude` or `longitude` attribute was not found in the specified device tracker."
},
"abort": {
- "already_configured": "Warncell ID / name is already configured.",
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]"
}
},
diff --git a/homeassistant/components/dwd_weather_warnings/util.py b/homeassistant/components/dwd_weather_warnings/util.py
new file mode 100644
index 00000000000..730ebf4b71e
--- /dev/null
+++ b/homeassistant/components/dwd_weather_warnings/util.py
@@ -0,0 +1,39 @@
+"""Util functions for the dwd_weather_warnings integration."""
+
+from __future__ import annotations
+
+from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from .exceptions import EntityNotFoundError
+
+
+def get_position_data(
+ hass: HomeAssistant, registry_id: str
+) -> tuple[float, float] | None:
+ """Extract longitude and latitude from a device tracker."""
+ registry = er.async_get(hass)
+ registry_entry = registry.async_get(registry_id)
+ if registry_entry is None:
+ raise EntityNotFoundError(f"Failed to find registry entry {registry_id}")
+
+ entity = hass.states.get(registry_entry.entity_id)
+ if entity is None:
+ raise EntityNotFoundError(f"Failed to find entity {registry_entry.entity_id}")
+
+ latitude = entity.attributes.get(ATTR_LATITUDE)
+ if not latitude:
+ raise AttributeError(
+ f"Failed to find attribute '{ATTR_LATITUDE}' in {registry_entry.entity_id}",
+ ATTR_LATITUDE,
+ )
+
+ longitude = entity.attributes.get(ATTR_LONGITUDE)
+ if not longitude:
+ raise AttributeError(
+ f"Failed to find attribute '{ATTR_LONGITUDE}' in {registry_entry.entity_id}",
+ ATTR_LONGITUDE,
+ )
+
+ return (latitude, longitude)
diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py
index 8083d0efcb4..6f032fbaae9 100644
--- a/homeassistant/components/ecobee/__init__.py
+++ b/homeassistant/components/ecobee/__init__.py
@@ -73,6 +73,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ # The legacy Ecobee notify.notify service is deprecated
+ # was with HA Core 2024.5.0 and will be removed with HA core 2024.11.0
hass.async_create_task(
discovery.async_load_platform(
hass,
@@ -97,7 +99,7 @@ class EcobeeData:
) -> None:
"""Initialize the Ecobee data object."""
self._hass = hass
- self._entry = entry
+ self.entry = entry
self.ecobee = Ecobee(
config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token}
)
@@ -117,7 +119,7 @@ class EcobeeData:
_LOGGER.debug("Refreshing ecobee tokens and updating config entry")
if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens):
self._hass.config_entries.async_update_entry(
- self._entry,
+ self.entry,
data={
CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY],
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py
index e341f4176ad..11675c0bf61 100644
--- a/homeassistant/components/ecobee/climate.py
+++ b/homeassistant/components/ecobee/climate.py
@@ -12,7 +12,10 @@ from homeassistant.components.climate import (
ATTR_TARGET_TEMP_LOW,
FAN_AUTO,
FAN_ON,
+ PRESET_AWAY,
+ PRESET_HOME,
PRESET_NONE,
+ PRESET_SLEEP,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -60,9 +63,6 @@ PRESET_TEMPERATURE = "temp"
PRESET_VACATION = "vacation"
PRESET_HOLD_NEXT_TRANSITION = "next_transition"
PRESET_HOLD_INDEFINITE = "indefinite"
-AWAY_MODE = "awayMode"
-PRESET_HOME = "home"
-PRESET_SLEEP = "sleep"
HAS_HEAT_PUMP = "hasHeatPump"
DEFAULT_MIN_HUMIDITY = 15
@@ -103,6 +103,13 @@ ECOBEE_HVAC_ACTION_TO_HASS = {
"compWaterHeater": None,
}
+ECOBEE_TO_HASS_PRESET = {
+ "Away": PRESET_AWAY,
+ "Home": PRESET_HOME,
+ "Sleep": PRESET_SLEEP,
+}
+HASS_TO_ECOBEE_PRESET = {v: k for k, v in ECOBEE_TO_HASS_PRESET.items()}
+
PRESET_TO_ECOBEE_HOLD = {
PRESET_HOLD_NEXT_TRANSITION: "nextTransition",
PRESET_HOLD_INDEFINITE: "indefinite",
@@ -348,10 +355,6 @@ class Thermostat(ClimateEntity):
self._attr_hvac_modes.insert(0, HVACMode.HEAT_COOL)
self._attr_hvac_modes.append(HVACMode.OFF)
- self._preset_modes = {
- comfort["climateRef"]: comfort["name"]
- for comfort in self.thermostat["program"]["climates"]
- }
self.update_without_throttle = False
async def async_update(self) -> None:
@@ -474,7 +477,7 @@ class Thermostat(ClimateEntity):
return self.thermostat["runtime"]["desiredFanMode"]
@property
- def preset_mode(self):
+ def preset_mode(self) -> str | None:
"""Return current preset mode."""
events = self.thermostat["events"]
for event in events:
@@ -487,8 +490,8 @@ class Thermostat(ClimateEntity):
):
return PRESET_AWAY_INDEFINITELY
- if event["holdClimateRef"] in self._preset_modes:
- return self._preset_modes[event["holdClimateRef"]]
+ if name := self.comfort_settings.get(event["holdClimateRef"]):
+ return ECOBEE_TO_HASS_PRESET.get(name, name)
# Any hold not based on a climate is a temp hold
return PRESET_TEMPERATURE
@@ -499,7 +502,12 @@ class Thermostat(ClimateEntity):
self.vacation = event["name"]
return PRESET_VACATION
- return self._preset_modes[self.thermostat["program"]["currentClimateRef"]]
+ if name := self.comfort_settings.get(
+ self.thermostat["program"]["currentClimateRef"]
+ ):
+ return ECOBEE_TO_HASS_PRESET.get(name, name)
+
+ return None
@property
def hvac_mode(self):
@@ -545,14 +553,14 @@ class Thermostat(ClimateEntity):
return HVACAction.IDLE
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return device specific state attributes."""
status = self.thermostat["equipmentStatus"]
return {
"fan": self.fan,
- "climate_mode": self._preset_modes[
+ "climate_mode": self.comfort_settings.get(
self.thermostat["program"]["currentClimateRef"]
- ],
+ ),
"equipment_running": status,
"fan_min_on_time": self.settings["fanMinOnTime"],
}
@@ -577,6 +585,8 @@ class Thermostat(ClimateEntity):
def set_preset_mode(self, preset_mode: str) -> None:
"""Activate a preset."""
+ preset_mode = HASS_TO_ECOBEE_PRESET.get(preset_mode, preset_mode)
+
if preset_mode == self.preset_mode:
return
@@ -605,25 +615,14 @@ class Thermostat(ClimateEntity):
elif preset_mode == PRESET_NONE:
self.data.ecobee.resume_program(self.thermostat_index)
- elif preset_mode in self.preset_modes:
- climate_ref = None
-
- for comfort in self.thermostat["program"]["climates"]:
- if comfort["name"] == preset_mode:
- climate_ref = comfort["climateRef"]
+ else:
+ for climate_ref, name in self.comfort_settings.items():
+ if name == preset_mode:
+ preset_mode = climate_ref
break
-
- if climate_ref is not None:
- self.data.ecobee.set_climate_hold(
- self.thermostat_index,
- climate_ref,
- self.hold_preference(),
- self.hold_hours(),
- )
else:
_LOGGER.warning("Received unknown preset mode: %s", preset_mode)
- else:
self.data.ecobee.set_climate_hold(
self.thermostat_index,
preset_mode,
@@ -632,11 +631,22 @@ class Thermostat(ClimateEntity):
)
@property
- def preset_modes(self):
+ def preset_modes(self) -> list[str] | None:
"""Return available preset modes."""
# Return presets provided by the ecobee API, and an indefinite away
# preset which we handle separately in set_preset_mode().
- return [*self._preset_modes.values(), PRESET_AWAY_INDEFINITELY]
+ return [
+ ECOBEE_TO_HASS_PRESET.get(name, name)
+ for name in self.comfort_settings.values()
+ ] + [PRESET_AWAY_INDEFINITELY]
+
+ @property
+ def comfort_settings(self) -> dict[str, str]:
+ """Return ecobee API comfort settings."""
+ return {
+ comfort["climateRef"]: comfort["name"]
+ for comfort in self.thermostat["program"]["climates"]
+ }
def set_auto_temp_hold(self, heat_temp, cool_temp):
"""Set temperature hold in auto mode."""
diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py
index e20acb5cfca..0eed0ab67f9 100644
--- a/homeassistant/components/ecobee/const.py
+++ b/homeassistant/components/ecobee/const.py
@@ -46,6 +46,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.HUMIDIFIER,
+ Platform.NOTIFY,
Platform.NUMBER,
Platform.SENSOR,
Platform.WEATHER,
diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json
index f3f5b59a36f..7e461230600 100644
--- a/homeassistant/components/ecobee/manifest.json
+++ b/homeassistant/components/ecobee/manifest.json
@@ -3,6 +3,7 @@
"name": "ecobee",
"codeowners": [],
"config_flow": true,
+ "dependencies": ["http", "repairs"],
"documentation": "https://www.home-assistant.io/integrations/ecobee",
"homekit": {
"models": ["EB", "ecobee*"]
diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py
index b2f6ccb05c8..787130c403f 100644
--- a/homeassistant/components/ecobee/notify.py
+++ b/homeassistant/components/ecobee/notify.py
@@ -2,11 +2,23 @@
from __future__ import annotations
-from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
+from functools import partial
+from typing import Any
+
+from homeassistant.components.notify import (
+ ATTR_TARGET,
+ BaseNotificationService,
+ NotifyEntity,
+)
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from . import Ecobee, EcobeeData
from .const import DOMAIN
+from .entity import EcobeeBaseEntity
+from .repairs import migrate_notify_issue
def get_service(
@@ -18,18 +30,25 @@ def get_service(
if discovery_info is None:
return None
- data = hass.data[DOMAIN]
+ data: EcobeeData = hass.data[DOMAIN]
return EcobeeNotificationService(data.ecobee)
class EcobeeNotificationService(BaseNotificationService):
"""Implement the notification service for the Ecobee thermostat."""
- def __init__(self, ecobee):
+ def __init__(self, ecobee: Ecobee) -> None:
"""Initialize the service."""
self.ecobee = ecobee
- def send_message(self, message="", **kwargs):
+ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
+ """Send a message and raise issue."""
+ migrate_notify_issue(self.hass)
+ await self.hass.async_add_executor_job(
+ partial(self.send_message, message, **kwargs)
+ )
+
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message."""
targets = kwargs.get(ATTR_TARGET)
@@ -39,3 +58,33 @@ class EcobeeNotificationService(BaseNotificationService):
for target in targets:
thermostat_index = int(target)
self.ecobee.send_message(thermostat_index, message)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the ecobee thermostat."""
+ data: EcobeeData = hass.data[DOMAIN]
+ async_add_entities(
+ EcobeeNotifyEntity(data, index) for index in range(len(data.ecobee.thermostats))
+ )
+
+
+class EcobeeNotifyEntity(EcobeeBaseEntity, NotifyEntity):
+ """Implement the notification entity for the Ecobee thermostat."""
+
+ _attr_name = None
+ _attr_has_entity_name = True
+
+ def __init__(self, data: EcobeeData, thermostat_index: int) -> None:
+ """Initialize the thermostat."""
+ super().__init__(data, thermostat_index)
+ self._attr_unique_id = (
+ f"{self.thermostat["identifier"]}_notify_{thermostat_index}"
+ )
+
+ def send_message(self, message: str) -> None:
+ """Send a message."""
+ self.data.ecobee.send_message(self.thermostat_index, message)
diff --git a/homeassistant/components/ecobee/repairs.py b/homeassistant/components/ecobee/repairs.py
new file mode 100644
index 00000000000..66474730b2f
--- /dev/null
+++ b/homeassistant/components/ecobee/repairs.py
@@ -0,0 +1,37 @@
+"""Repairs support for Ecobee."""
+
+from __future__ import annotations
+
+from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
+from homeassistant.components.repairs import RepairsFlow
+from homeassistant.components.repairs.issue_handler import ConfirmRepairFlow
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import issue_registry as ir
+
+from .const import DOMAIN
+
+
+@callback
+def migrate_notify_issue(hass: HomeAssistant) -> None:
+ """Ensure an issue is registered."""
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "migrate_notify",
+ breaks_in_ha_version="2024.11.0",
+ issue_domain=NOTIFY_DOMAIN,
+ is_fixable=True,
+ is_persistent=True,
+ translation_key="migrate_notify",
+ severity=ir.IssueSeverity.WARNING,
+ )
+
+
+async def async_create_fix_flow(
+ hass: HomeAssistant,
+ issue_id: str,
+ data: dict[str, str | int | float | None] | None,
+) -> RepairsFlow:
+ """Create flow."""
+ assert issue_id == "migrate_notify"
+ return ConfirmRepairFlow()
diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json
index b1d1df65417..1d64b6d6b94 100644
--- a/homeassistant/components/ecobee/strings.json
+++ b/homeassistant/components/ecobee/strings.json
@@ -163,5 +163,18 @@
}
}
}
+ },
+ "issues": {
+ "migrate_notify": {
+ "title": "Migration of Ecobee notify service",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "description": "The Ecobee `notify` service has been migrated. A new `notify` entity per Thermostat is available now.\n\nUpdate any automations to use the new `notify.send_message` exposed by these new entities. When this is done, fix this issue and restart Home Assistant.",
+ "title": "Disable legacy Ecobee notify service"
+ }
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py
index a1ea19144b0..4a421113f5f 100644
--- a/homeassistant/components/ecovacs/config_flow.py
+++ b/homeassistant/components/ecovacs/config_flow.py
@@ -71,7 +71,7 @@ async def _validate_input(
if errors:
return errors
- device_id = get_client_device_id()
+ device_id = get_client_device_id(hass, rest_url is not None)
country = user_input[CONF_COUNTRY]
rest_config = create_rest_config(
aiohttp_client.async_get_clientsession(hass),
diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py
index e5ef0760182..6b77404e935 100644
--- a/homeassistant/components/ecovacs/const.py
+++ b/homeassistant/components/ecovacs/const.py
@@ -12,8 +12,10 @@ CONF_OVERRIDE_MQTT_URL = "override_mqtt_url"
CONF_VERIFY_MQTT_CERTIFICATE = "verify_mqtt_certificate"
SUPPORTED_LIFESPANS = (
+ LifeSpan.BLADE,
LifeSpan.BRUSH,
LifeSpan.FILTER,
+ LifeSpan.LENS_BRUSH,
LifeSpan.SIDE_BRUSH,
)
diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py
index 5defcdf861f..6b6fe3128dd 100644
--- a/homeassistant/components/ecovacs/controller.py
+++ b/homeassistant/components/ecovacs/controller.py
@@ -43,7 +43,8 @@ class EcovacsController:
self._hass = hass
self._devices: list[Device] = []
self.legacy_devices: list[VacBot] = []
- self._device_id = get_client_device_id()
+ rest_url = config.get(CONF_OVERRIDE_REST_URL)
+ self._device_id = get_client_device_id(hass, rest_url is not None)
country = config[CONF_COUNTRY]
self._continent = get_continent(country)
@@ -52,7 +53,7 @@ class EcovacsController:
aiohttp_client.async_get_clientsession(self._hass),
device_id=self._device_id,
alpha_2_country=country,
- override_rest_url=config.get(CONF_OVERRIDE_REST_URL),
+ override_rest_url=rest_url,
),
config[CONF_USERNAME],
md5(config[CONF_PASSWORD]),
diff --git a/homeassistant/components/ecovacs/event.py b/homeassistant/components/ecovacs/event.py
index daac4a626ae..fb4c25c7559 100644
--- a/homeassistant/components/ecovacs/event.py
+++ b/homeassistant/components/ecovacs/event.py
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .controller import EcovacsController
from .entity import EcovacsEntity
+from .util import get_name_key
async def async_setup_entry(
@@ -54,10 +55,7 @@ class EcovacsLastJobEventEntity(
# we trigger only on job done
return
- event_type = event.status.name.lower()
- if event.status == CleanJobStatus.MANUAL_STOPPED:
- event_type = "manually_stopped"
-
+ event_type = get_name_key(event.status)
self._trigger_event(event_type)
self.async_write_ha_state()
diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json
index 2e2d897c455..44c577104dd 100644
--- a/homeassistant/components/ecovacs/icons.json
+++ b/homeassistant/components/ecovacs/icons.json
@@ -12,12 +12,18 @@
"relocate": {
"default": "mdi:map-marker-question"
},
+ "reset_lifespan_blade": {
+ "default": "mdi:saw-blade"
+ },
"reset_lifespan_brush": {
"default": "mdi:broom"
},
"reset_lifespan_filter": {
"default": "mdi:air-filter"
},
+ "reset_lifespan_lens_brush": {
+ "default": "mdi:broom"
+ },
"reset_lifespan_side_brush": {
"default": "mdi:broom"
}
@@ -42,12 +48,18 @@
"error": {
"default": "mdi:alert-circle"
},
+ "lifespan_blade": {
+ "default": "mdi:saw-blade"
+ },
"lifespan_brush": {
"default": "mdi:broom"
},
"lifespan_filter": {
"default": "mdi:air-filter"
},
+ "lifespan_lens_brush": {
+ "default": "mdi:broom"
+ },
"lifespan_side_brush": {
"default": "mdi:broom"
},
diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json
index 52753e6eb39..aad04d9ec87 100644
--- a/homeassistant/components/ecovacs/manifest.json
+++ b/homeassistant/components/ecovacs/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
- "requirements": ["py-sucks==0.9.9", "deebot-client==6.0.2"]
+ "requirements": ["py-sucks==0.9.9", "deebot-client==7.1.0"]
}
diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py
index 8a3def54e28..01d4c5aae6b 100644
--- a/homeassistant/components/ecovacs/select.py
+++ b/homeassistant/components/ecovacs/select.py
@@ -22,7 +22,7 @@ from .entity import (
EcovacsDescriptionEntity,
EventT,
)
-from .util import get_supported_entitites
+from .util import get_name_key, get_supported_entitites
@dataclass(kw_only=True, frozen=True)
@@ -41,8 +41,8 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = (
EcovacsSelectEntityDescription[WaterInfoEvent](
device_capabilities=VacuumCapabilities,
capability_fn=lambda caps: caps.water,
- current_option_fn=lambda e: e.amount.display_name,
- options_fn=lambda water: [amount.display_name for amount in water.types],
+ current_option_fn=lambda e: get_name_key(e.amount),
+ options_fn=lambda water: [get_name_key(amount) for amount in water.types],
key="water_amount",
translation_key="water_amount",
entity_category=EntityCategory.CONFIG,
@@ -50,8 +50,8 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = (
EcovacsSelectEntityDescription[WorkModeEvent](
device_capabilities=VacuumCapabilities,
capability_fn=lambda caps: caps.clean.work_mode,
- current_option_fn=lambda e: e.mode.display_name,
- options_fn=lambda cap: [mode.display_name for mode in cap.types],
+ current_option_fn=lambda e: get_name_key(e.mode),
+ options_fn=lambda cap: [get_name_key(mode) for mode in cap.types],
key="work_mode",
translation_key="work_mode",
entity_registry_enabled_default=False,
diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json
index 50afd21deb3..bb27bd6941d 100644
--- a/homeassistant/components/ecovacs/strings.json
+++ b/homeassistant/components/ecovacs/strings.json
@@ -46,12 +46,18 @@
"relocate": {
"name": "Relocate"
},
+ "reset_lifespan_blade": {
+ "name": "Reset blade lifespan"
+ },
"reset_lifespan_brush": {
"name": "Reset main brush lifespan"
},
"reset_lifespan_filter": {
"name": "Reset filter lifespan"
},
+ "reset_lifespan_lens_brush": {
+ "name": "Reset lens brush lifespan"
+ },
"reset_lifespan_side_brush": {
"name": "Reset side brushes lifespan"
}
@@ -92,12 +98,18 @@
}
}
},
+ "lifespan_blade": {
+ "name": "Blade lifespan"
+ },
"lifespan_brush": {
"name": "Main brush lifespan"
},
"lifespan_filter": {
"name": "Filter lifespan"
},
+ "lifespan_lens_brush": {
+ "name": "Lens brush lifespan"
+ },
"lifespan_side_brush": {
"name": "Side brushes lifespan"
},
diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py
index 14e69cd4b61..9d692bbbb8f 100644
--- a/homeassistant/components/ecovacs/util.py
+++ b/homeassistant/components/ecovacs/util.py
@@ -2,12 +2,16 @@
from __future__ import annotations
+from enum import Enum
import random
import string
from typing import TYPE_CHECKING
from deebot_client.capabilities import Capabilities
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.util import slugify
+
from .entity import (
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
@@ -18,8 +22,11 @@ if TYPE_CHECKING:
from .controller import EcovacsController
-def get_client_device_id() -> str:
+def get_client_device_id(hass: HomeAssistant, self_hosted: bool) -> str:
"""Get client device id."""
+ if self_hosted:
+ return f"HA-{slugify(hass.config.location_name)}"
+
return "".join(
random.choice(string.ascii_uppercase + string.digits) for _ in range(8)
)
@@ -38,3 +45,9 @@ def get_supported_entitites(
if isinstance(device.capabilities, description.device_capabilities)
if (capability := description.capability_fn(device.capabilities))
]
+
+
+@callback
+def get_name_key(enum: Enum) -> str:
+ """Return the lower case name of the enum."""
+ return enum.name.lower()
diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py
index d5016ab683d..0e990645d7c 100644
--- a/homeassistant/components/ecovacs/vacuum.py
+++ b/homeassistant/components/ecovacs/vacuum.py
@@ -33,6 +33,7 @@ from homeassistant.util import slugify
from .const import DOMAIN
from .controller import EcovacsController
from .entity import EcovacsEntity
+from .util import get_name_key
_LOGGER = logging.getLogger(__name__)
@@ -242,7 +243,7 @@ class EcovacsVacuum(
self._rooms: list[Room] = []
self._attr_fan_speed_list = [
- level.display_name for level in capabilities.fan_speed.types
+ get_name_key(level) for level in capabilities.fan_speed.types
]
async def async_added_to_hass(self) -> None:
@@ -254,7 +255,7 @@ class EcovacsVacuum(
self.async_write_ha_state()
async def on_fan_speed(event: FanSpeedEvent) -> None:
- self._attr_fan_speed = event.speed.display_name
+ self._attr_fan_speed = get_name_key(event.speed)
self.async_write_ha_state()
async def on_rooms(event: RoomsEvent) -> None:
diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py
index ab3f2671b99..7de3a4f2ef8 100644
--- a/homeassistant/components/emoncms_history/__init__.py
+++ b/homeassistant/components/emoncms_history/__init__.py
@@ -86,8 +86,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
continue
if payload_dict:
- payload = "{%s}" % ",".join(
- f"{key}:{val}" for key, val in payload_dict.items()
+ payload = "{{{}}}".format(
+ ",".join(f"{key}:{val}" for key, val in payload_dict.items())
)
send_data(
diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py
index 37930e31af0..147d8f3e26a 100644
--- a/homeassistant/components/energy/sensor.py
+++ b/homeassistant/components/energy/sensor.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
-from collections.abc import Callable
+from collections.abc import Callable, Mapping
import copy
from dataclasses import dataclass
import logging
@@ -167,8 +167,7 @@ class SensorManager:
if adapter.flow_type is None:
self._process_sensor_data(
adapter,
- # Opting out of the type complexity because can't get it to work
- energy_source, # type: ignore[arg-type]
+ energy_source,
to_add,
to_remove,
)
@@ -177,8 +176,7 @@ class SensorManager:
for flow in energy_source[adapter.flow_type]: # type: ignore[typeddict-item]
self._process_sensor_data(
adapter,
- # Opting out of the type complexity because can't get it to work
- flow, # type: ignore[arg-type]
+ flow,
to_add,
to_remove,
)
@@ -189,7 +187,7 @@ class SensorManager:
def _process_sensor_data(
self,
adapter: SourceAdapter,
- config: dict,
+ config: Mapping[str, Any],
to_add: list[EnergyCostSensor],
to_remove: dict[tuple[str, str | None, str], EnergyCostSensor],
) -> None:
@@ -241,7 +239,7 @@ class EnergyCostSensor(SensorEntity):
def __init__(
self,
adapter: SourceAdapter,
- config: dict,
+ config: Mapping[str, Any],
) -> None:
"""Initialize the sensor."""
super().__init__()
@@ -456,7 +454,7 @@ class EnergyCostSensor(SensorEntity):
await super().async_will_remove_from_hass()
@callback
- def update_config(self, config: dict) -> None:
+ def update_config(self, config: Mapping[str, Any]) -> None:
"""Update the config."""
self._config = config
diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py
index 2dd45a8be4d..2b5b71d3e2f 100644
--- a/homeassistant/components/energy/websocket_api.py
+++ b/homeassistant/components/energy/websocket_api.py
@@ -31,7 +31,7 @@ from .data import (
EnergyPreferencesUpdate,
async_get_manager,
)
-from .types import EnergyPlatform, GetSolarForecastType
+from .types import EnergyPlatform, GetSolarForecastType, SolarForecastType
from .validate import async_validate
EnergyWebSocketCommandHandler = Callable[
@@ -203,19 +203,18 @@ async def ws_solar_forecast(
for source in manager.data["energy_sources"]:
if (
source["type"] != "solar"
- or source.get("config_entry_solar_forecast") is None
+ or (solar_forecast := source.get("config_entry_solar_forecast")) is None
):
continue
- # typing is not catching the above guard for config_entry_solar_forecast being none
- for config_entry in source["config_entry_solar_forecast"]: # type: ignore[union-attr]
- config_entries[config_entry] = None
+ for entry in solar_forecast:
+ config_entries[entry] = None
if not config_entries:
connection.send_result(msg["id"], {})
return
- forecasts = {}
+ forecasts: dict[str, SolarForecastType] = {}
forecast_platforms = await async_get_energy_platforms(hass)
diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py
index 2407f807eb7..322f909437a 100644
--- a/homeassistant/components/enphase_envoy/__init__.py
+++ b/homeassistant/components/enphase_envoy/__init__.py
@@ -46,6 +46,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
+ coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator.async_cancel_token_refresh()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py
index 13894d423d6..5f859d16142 100644
--- a/homeassistant/components/enphase_envoy/config_flow.py
+++ b/homeassistant/components/enphase_envoy/config_flow.py
@@ -89,6 +89,14 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ current_hosts = self._async_current_hosts()
+ _LOGGER.debug(
+ "Zeroconf ip %s processing %s, current hosts: %s",
+ discovery_info.ip_address.version,
+ discovery_info.host,
+ current_hosts,
+ )
if discovery_info.ip_address.version != 4:
return self.async_abort(reason="not_ipv4_address")
serial = discovery_info.properties["serialnum"]
@@ -96,17 +104,27 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(serial)
self.ip_address = discovery_info.host
self._abort_if_unique_id_configured({CONF_HOST: self.ip_address})
+ _LOGGER.debug(
+ "Zeroconf ip %s, fw %s, no existing entry with serial %s",
+ self.ip_address,
+ self.protovers,
+ serial,
+ )
for entry in self._async_current_entries(include_ignore=False):
if (
entry.unique_id is None
and CONF_HOST in entry.data
and entry.data[CONF_HOST] == self.ip_address
):
+ _LOGGER.debug(
+ "Zeroconf update envoy with this ip and blank serial in unique_id",
+ )
title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY
return self.async_update_reload_and_abort(
entry, title=title, unique_id=serial, reason="already_configured"
)
+ _LOGGER.debug("Zeroconf ip %s to step user", self.ip_address)
return await self.async_step_user()
async def async_step_reauth(
diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py
index a508d5127d6..04f93098ad9 100644
--- a/homeassistant/components/enphase_envoy/coordinator.py
+++ b/homeassistant/components/enphase_envoy/coordinator.py
@@ -83,9 +83,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def _async_mark_setup_complete(self) -> None:
"""Mark setup as complete and setup token refresh if needed."""
self._setup_complete = True
- if self._cancel_token_refresh:
- self._cancel_token_refresh()
- self._cancel_token_refresh = None
+ self.async_cancel_token_refresh()
if not isinstance(self.envoy.auth, EnvoyTokenAuth):
return
self._cancel_token_refresh = async_track_time_interval(
@@ -159,3 +157,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return envoy_data.raw
raise RuntimeError("Unreachable code in _async_update_data") # pragma: no cover
+
+ @callback
+ def async_cancel_token_refresh(self) -> None:
+ """Cancel token refresh."""
+ if self._cancel_token_refresh:
+ self._cancel_token_refresh()
+ self._cancel_token_refresh = None
diff --git a/homeassistant/components/epic_games_store/__init__.py b/homeassistant/components/epic_games_store/__init__.py
new file mode 100644
index 00000000000..af25eb98137
--- /dev/null
+++ b/homeassistant/components/epic_games_store/__init__.py
@@ -0,0 +1,35 @@
+"""The Epic Games Store integration."""
+
+from __future__ import annotations
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .const import DOMAIN
+from .coordinator import EGSCalendarUpdateCoordinator
+
+PLATFORMS: list[Platform] = [
+ Platform.CALENDAR,
+]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Epic Games Store from a config entry."""
+
+ coordinator = EGSCalendarUpdateCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
diff --git a/homeassistant/components/epic_games_store/calendar.py b/homeassistant/components/epic_games_store/calendar.py
new file mode 100644
index 00000000000..75c448e8467
--- /dev/null
+++ b/homeassistant/components/epic_games_store/calendar.py
@@ -0,0 +1,97 @@
+"""Calendar platform for a Epic Games Store."""
+
+from __future__ import annotations
+
+from collections import namedtuple
+from datetime import datetime
+from typing import Any
+
+from homeassistant.components.calendar import CalendarEntity, CalendarEvent
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, CalendarType
+from .coordinator import EGSCalendarUpdateCoordinator
+
+DateRange = namedtuple("DateRange", ["start", "end"])
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the local calendar platform."""
+ coordinator: EGSCalendarUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+
+ entities = [
+ EGSCalendar(coordinator, entry.entry_id, CalendarType.FREE),
+ EGSCalendar(coordinator, entry.entry_id, CalendarType.DISCOUNT),
+ ]
+ async_add_entities(entities)
+
+
+class EGSCalendar(CoordinatorEntity[EGSCalendarUpdateCoordinator], CalendarEntity):
+ """A calendar entity by Epic Games Store."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: EGSCalendarUpdateCoordinator,
+ config_entry_id: str,
+ cal_type: CalendarType,
+ ) -> None:
+ """Initialize EGSCalendar."""
+ super().__init__(coordinator)
+ self._cal_type = cal_type
+ self._attr_translation_key = f"{cal_type}_games"
+ self._attr_unique_id = f"{config_entry_id}-{cal_type}"
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, config_entry_id)},
+ manufacturer="Epic Games Store",
+ name="Epic Games Store",
+ )
+
+ @property
+ def event(self) -> CalendarEvent | None:
+ """Return the next upcoming event."""
+ if event := self.coordinator.data[self._cal_type]:
+ return _get_calendar_event(event[0])
+ return None
+
+ async def async_get_events(
+ self, hass: HomeAssistant, start_date: datetime, end_date: datetime
+ ) -> list[CalendarEvent]:
+ """Get all events in a specific time frame."""
+ events = filter(
+ lambda game: _are_date_range_overlapping(
+ DateRange(start=game["discount_start_at"], end=game["discount_end_at"]),
+ DateRange(start=start_date, end=end_date),
+ ),
+ self.coordinator.data[self._cal_type],
+ )
+ return [_get_calendar_event(event) for event in events]
+
+
+def _get_calendar_event(event: dict[str, Any]) -> CalendarEvent:
+ """Return a CalendarEvent from an API event."""
+ return CalendarEvent(
+ summary=event["title"],
+ start=event["discount_start_at"],
+ end=event["discount_end_at"],
+ description=f"{event['description']}\n\n{event['url']}",
+ )
+
+
+def _are_date_range_overlapping(range1: DateRange, range2: DateRange) -> bool:
+ """Return a CalendarEvent from an API event."""
+ latest_start = max(range1.start, range2.start)
+ earliest_end = min(range1.end, range2.end)
+ delta = (earliest_end - latest_start).days + 1
+ overlap = max(0, delta)
+ return overlap > 0
diff --git a/homeassistant/components/epic_games_store/config_flow.py b/homeassistant/components/epic_games_store/config_flow.py
new file mode 100644
index 00000000000..2ae86060ba2
--- /dev/null
+++ b/homeassistant/components/epic_games_store/config_flow.py
@@ -0,0 +1,96 @@
+"""Config flow for Epic Games Store integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from epicstore_api import EpicGamesStoreAPI
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.config_entries import ConfigFlowResult
+from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.selector import (
+ CountrySelector,
+ LanguageSelector,
+ LanguageSelectorConfig,
+)
+
+from .const import DOMAIN, SUPPORTED_LANGUAGES
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_LANGUAGE): LanguageSelector(
+ LanguageSelectorConfig(languages=SUPPORTED_LANGUAGES)
+ ),
+ vol.Required(CONF_COUNTRY): CountrySelector(),
+ }
+)
+
+
+def get_default_language(hass: HomeAssistant) -> str | None:
+ """Get default language code based on Home Assistant config."""
+ language_code = f"{hass.config.language}-{hass.config.country}"
+ if language_code in SUPPORTED_LANGUAGES:
+ return language_code
+ if hass.config.language in SUPPORTED_LANGUAGES:
+ return hass.config.language
+ return None
+
+
+async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> None:
+ """Validate the user input allows us to connect."""
+ api = EpicGamesStoreAPI(user_input[CONF_LANGUAGE], user_input[CONF_COUNTRY])
+ data = await hass.async_add_executor_job(api.get_free_games)
+
+ if data.get("errors"):
+ _LOGGER.warning(data["errors"])
+
+ assert data["data"]["Catalog"]["searchStore"]["elements"]
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Epic Games Store."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ data_schema = self.add_suggested_values_to_schema(
+ STEP_USER_DATA_SCHEMA,
+ user_input
+ or {
+ CONF_LANGUAGE: get_default_language(self.hass),
+ CONF_COUNTRY: self.hass.config.country,
+ },
+ )
+ if user_input is None:
+ return self.async_show_form(step_id="user", data_schema=data_schema)
+
+ await self.async_set_unique_id(
+ f"freegames-{user_input[CONF_LANGUAGE]}-{user_input[CONF_COUNTRY]}"
+ )
+ self._abort_if_unique_id_configured()
+
+ errors = {}
+
+ try:
+ await validate_input(self.hass, user_input)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title=f"Epic Games Store - Free Games ({user_input[CONF_LANGUAGE]}-{user_input[CONF_COUNTRY]})",
+ data=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=data_schema, errors=errors
+ )
diff --git a/homeassistant/components/epic_games_store/const.py b/homeassistant/components/epic_games_store/const.py
new file mode 100644
index 00000000000..c397698fd0c
--- /dev/null
+++ b/homeassistant/components/epic_games_store/const.py
@@ -0,0 +1,31 @@
+"""Constants for the Epic Games Store integration."""
+
+from enum import StrEnum
+
+DOMAIN = "epic_games_store"
+
+SUPPORTED_LANGUAGES = [
+ "ar",
+ "de",
+ "en-US",
+ "es-ES",
+ "es-MX",
+ "fr",
+ "it",
+ "ja",
+ "ko",
+ "pl",
+ "pt-BR",
+ "ru",
+ "th",
+ "tr",
+ "zh-CN",
+ "zh-Hant",
+]
+
+
+class CalendarType(StrEnum):
+ """Calendar types."""
+
+ FREE = "free"
+ DISCOUNT = "discount"
diff --git a/homeassistant/components/epic_games_store/coordinator.py b/homeassistant/components/epic_games_store/coordinator.py
new file mode 100644
index 00000000000..d9c48f5da02
--- /dev/null
+++ b/homeassistant/components/epic_games_store/coordinator.py
@@ -0,0 +1,81 @@
+"""The Epic Games Store integration data coordinator."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+from typing import Any
+
+from epicstore_api import EpicGamesStoreAPI
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN, CalendarType
+from .helper import format_game_data
+
+SCAN_INTERVAL = timedelta(days=1)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class EGSCalendarUpdateCoordinator(
+ DataUpdateCoordinator[dict[str, list[dict[str, Any]]]]
+):
+ """Class to manage fetching data from the Epic Game Store."""
+
+ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Initialize."""
+ self._api = EpicGamesStoreAPI(
+ entry.data[CONF_LANGUAGE],
+ entry.data[CONF_COUNTRY],
+ )
+ self.language = entry.data[CONF_LANGUAGE]
+
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+
+ async def _async_update_data(self) -> dict[str, list[dict[str, Any]]]:
+ """Update data via library."""
+ raw_data = await self.hass.async_add_executor_job(self._api.get_free_games)
+ _LOGGER.debug(raw_data)
+ data = raw_data["data"]["Catalog"]["searchStore"]["elements"]
+
+ discount_games = filter(
+ lambda game: game.get("promotions")
+ and (
+ # Current discount(s)
+ game["promotions"]["promotionalOffers"]
+ or
+ # Upcoming discount(s)
+ game["promotions"]["upcomingPromotionalOffers"]
+ ),
+ data,
+ )
+
+ return_data: dict[str, list[dict[str, Any]]] = {
+ CalendarType.DISCOUNT: [],
+ CalendarType.FREE: [],
+ }
+ for discount_game in discount_games:
+ game = format_game_data(discount_game, self.language)
+
+ if game["discount_type"]:
+ return_data[game["discount_type"]].append(game)
+
+ return_data[CalendarType.DISCOUNT] = sorted(
+ return_data[CalendarType.DISCOUNT],
+ key=lambda game: game["discount_start_at"],
+ )
+ return_data[CalendarType.FREE] = sorted(
+ return_data[CalendarType.FREE], key=lambda game: game["discount_start_at"]
+ )
+
+ _LOGGER.debug(return_data)
+ return return_data
diff --git a/homeassistant/components/epic_games_store/helper.py b/homeassistant/components/epic_games_store/helper.py
new file mode 100644
index 00000000000..2510c7699e5
--- /dev/null
+++ b/homeassistant/components/epic_games_store/helper.py
@@ -0,0 +1,92 @@
+"""Helper for Epic Games Store."""
+
+import contextlib
+from typing import Any
+
+from homeassistant.util import dt as dt_util
+
+
+def format_game_data(raw_game_data: dict[str, Any], language: str) -> dict[str, Any]:
+ """Format raw API game data for Home Assistant users."""
+ img_portrait = None
+ img_landscape = None
+
+ for image in raw_game_data["keyImages"]:
+ if image["type"] == "OfferImageTall":
+ img_portrait = image["url"]
+ if image["type"] == "OfferImageWide":
+ img_landscape = image["url"]
+
+ current_promotions = raw_game_data["promotions"]["promotionalOffers"]
+ upcoming_promotions = raw_game_data["promotions"]["upcomingPromotionalOffers"]
+
+ promotion_data = {}
+ if (
+ current_promotions
+ and raw_game_data["price"]["totalPrice"]["discountPrice"] == 0
+ ):
+ promotion_data = current_promotions[0]["promotionalOffers"][0]
+ else:
+ promotion_data = (current_promotions or upcoming_promotions)[0][
+ "promotionalOffers"
+ ][0]
+
+ return {
+ "title": raw_game_data["title"].replace("\xa0", " "),
+ "description": raw_game_data["description"].strip().replace("\xa0", " "),
+ "released_at": dt_util.parse_datetime(raw_game_data["effectiveDate"]),
+ "original_price": raw_game_data["price"]["totalPrice"]["fmtPrice"][
+ "originalPrice"
+ ].replace("\xa0", " "),
+ "publisher": raw_game_data["seller"]["name"],
+ "url": get_game_url(raw_game_data, language),
+ "img_portrait": img_portrait,
+ "img_landscape": img_landscape,
+ "discount_type": ("free" if is_free_game(raw_game_data) else "discount")
+ if promotion_data
+ else None,
+ "discount_start_at": dt_util.parse_datetime(promotion_data["startDate"])
+ if promotion_data
+ else None,
+ "discount_end_at": dt_util.parse_datetime(promotion_data["endDate"])
+ if promotion_data
+ else None,
+ }
+
+
+def get_game_url(raw_game_data: dict[str, Any], language: str) -> str:
+ """Format raw API game data for Home Assistant users."""
+ url_bundle_or_product = "bundles" if raw_game_data["offerType"] == "BUNDLE" else "p"
+ url_slug: str | None = None
+ try:
+ url_slug = raw_game_data["offerMappings"][0]["pageSlug"]
+ except Exception: # pylint: disable=broad-except
+ with contextlib.suppress(Exception):
+ url_slug = raw_game_data["catalogNs"]["mappings"][0]["pageSlug"]
+
+ if not url_slug:
+ url_slug = raw_game_data["urlSlug"]
+
+ return f"https://store.epicgames.com/{language}/{url_bundle_or_product}/{url_slug}"
+
+
+def is_free_game(game: dict[str, Any]) -> bool:
+ """Return if the game is free or will be free."""
+ return (
+ # Current free game(s)
+ game["promotions"]["promotionalOffers"]
+ and game["promotions"]["promotionalOffers"][0]["promotionalOffers"][0][
+ "discountSetting"
+ ]["discountPercentage"]
+ == 0
+ and
+ # Checking current price, maybe not necessary
+ game["price"]["totalPrice"]["discountPrice"] == 0
+ ) or (
+ # Upcoming free game(s)
+ game["promotions"]["upcomingPromotionalOffers"]
+ and game["promotions"]["upcomingPromotionalOffers"][0]["promotionalOffers"][0][
+ "discountSetting"
+ ]["discountPercentage"]
+ == 0
+ )
diff --git a/homeassistant/components/epic_games_store/manifest.json b/homeassistant/components/epic_games_store/manifest.json
new file mode 100644
index 00000000000..665eaec6668
--- /dev/null
+++ b/homeassistant/components/epic_games_store/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "epic_games_store",
+ "name": "Epic Games Store",
+ "codeowners": ["@hacf-fr", "@Quentame"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/epic_games_store",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "requirements": ["epicstore-api==0.1.7"]
+}
diff --git a/homeassistant/components/epic_games_store/strings.json b/homeassistant/components/epic_games_store/strings.json
new file mode 100644
index 00000000000..58a87a55f81
--- /dev/null
+++ b/homeassistant/components/epic_games_store/strings.json
@@ -0,0 +1,38 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "language": "Language",
+ "country": "Country"
+ }
+ }
+ },
+ "error": {
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ }
+ },
+ "entity": {
+ "calendar": {
+ "free_games": {
+ "name": "Free games",
+ "state_attributes": {
+ "games": {
+ "name": "Games"
+ }
+ }
+ },
+ "discount_games": {
+ "name": "Discount games",
+ "state_attributes": {
+ "games": {
+ "name": "[%key:component::epic_games_store::entity::calendar::free_games::state_attributes::games::name%]"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py
new file mode 100644
index 00000000000..15509a46158
--- /dev/null
+++ b/homeassistant/components/esphome/datetime.py
@@ -0,0 +1,48 @@
+"""Support for esphome datetimes."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from aioesphomeapi import DateTimeInfo, DateTimeState
+
+from homeassistant.components.datetime import DateTimeEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+import homeassistant.util.dt as dt_util
+
+from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up esphome datetimes based on a config entry."""
+ await platform_async_setup_entry(
+ hass,
+ entry,
+ async_add_entities,
+ info_type=DateTimeInfo,
+ entity_type=EsphomeDateTime,
+ state_type=DateTimeState,
+ )
+
+
+class EsphomeDateTime(EsphomeEntity[DateTimeInfo, DateTimeState], DateTimeEntity):
+ """A datetime implementation for esphome."""
+
+ @property
+ @esphome_state_property
+ def native_value(self) -> datetime | None:
+ """Return the state of the entity."""
+ state = self._state
+ if state.missing_state:
+ return None
+ return dt_util.utc_from_timestamp(state.epoch_seconds)
+
+ async def async_set_value(self, value: datetime) -> None:
+ """Update the current datetime."""
+ self._client.datetime_command(self._key, int(value.timestamp()))
diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py
index 52dc1f17ad6..41b18c9b88c 100644
--- a/homeassistant/components/esphome/entry_data.py
+++ b/homeassistant/components/esphome/entry_data.py
@@ -20,9 +20,12 @@ from aioesphomeapi import (
ClimateInfo,
CoverInfo,
DateInfo,
+ DateTimeInfo,
DeviceInfo,
EntityInfo,
EntityState,
+ Event,
+ EventInfo,
FanInfo,
LightInfo,
LockInfo,
@@ -46,9 +49,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store
-from homeassistant.util.signal_type import SignalType
from .const import DOMAIN
from .dashboard import async_get_dashboard
@@ -68,6 +69,8 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
ClimateInfo: Platform.CLIMATE,
CoverInfo: Platform.COVER,
DateInfo: Platform.DATE,
+ DateTimeInfo: Platform.DATETIME,
+ EventInfo: Platform.EVENT,
FanInfo: Platform.FAN,
LightInfo: Platform.LIGHT,
LockInfo: Platform.LOCK,
@@ -121,6 +124,9 @@ class RuntimeEntryData:
default_factory=dict
)
device_update_subscriptions: set[CALLBACK_TYPE] = field(default_factory=set)
+ static_info_update_subscriptions: set[Callable[[list[EntityInfo]], None]] = field(
+ default_factory=set
+ )
loaded_platforms: set[Platform] = field(default_factory=set)
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_storage_contents: StoreData | None = None
@@ -149,11 +155,6 @@ class RuntimeEntryData:
"_", " "
)
- @property
- def signal_static_info_updated(self) -> SignalType[list[EntityInfo]]:
- """Return the signal to listen to for updates on static info."""
- return SignalType(f"esphome_{self.entry_id}_on_list")
-
@callback
def async_register_static_info_callback(
self,
@@ -298,8 +299,9 @@ class RuntimeEntryData:
for callback_ in callbacks_:
callback_(entity_infos)
- # Then send dispatcher event
- async_dispatcher_send(hass, self.signal_static_info_updated, infos)
+ # Finally update static info subscriptions
+ for callback_ in self.static_info_update_subscriptions:
+ callback_(infos)
@callback
def async_subscribe_device_updated(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE:
@@ -312,6 +314,21 @@ class RuntimeEntryData:
"""Unsubscribe to device updates."""
self.device_update_subscriptions.remove(callback_)
+ @callback
+ def async_subscribe_static_info_updated(
+ self, callback_: Callable[[list[EntityInfo]], None]
+ ) -> CALLBACK_TYPE:
+ """Subscribe to static info updates."""
+ self.static_info_update_subscriptions.add(callback_)
+ return partial(self._async_unsubscribe_static_info_updated, callback_)
+
+ @callback
+ def _async_unsubscribe_static_info_updated(
+ self, callback_: Callable[[list[EntityInfo]], None]
+ ) -> None:
+ """Unsubscribe to static info updates."""
+ self.static_info_update_subscriptions.remove(callback_)
+
@callback
def async_subscribe_state_update(
self,
@@ -343,7 +360,7 @@ class RuntimeEntryData:
if (
current_state == state
and subscription_key not in stale_state
- and state_type is not CameraState
+ and state_type not in (CameraState, Event)
and not (
state_type is SensorState
and (platform_info := self.info.get(SensorInfo))
diff --git a/homeassistant/components/esphome/event.py b/homeassistant/components/esphome/event.py
new file mode 100644
index 00000000000..3c7331beba0
--- /dev/null
+++ b/homeassistant/components/esphome/event.py
@@ -0,0 +1,48 @@
+"""Support for ESPHome event components."""
+
+from __future__ import annotations
+
+from aioesphomeapi import EntityInfo, Event, EventInfo
+
+from homeassistant.components.event import EventDeviceClass, EventEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util.enum import try_parse_enum
+
+from .entity import EsphomeEntity, platform_async_setup_entry
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up ESPHome event based on a config entry."""
+ await platform_async_setup_entry(
+ hass,
+ entry,
+ async_add_entities,
+ info_type=EventInfo,
+ entity_type=EsphomeEvent,
+ state_type=Event,
+ )
+
+
+class EsphomeEvent(EsphomeEntity[EventInfo, Event], EventEntity):
+ """An event implementation for ESPHome."""
+
+ @callback
+ def _on_static_info_update(self, static_info: EntityInfo) -> None:
+ """Set attrs from static info."""
+ super()._on_static_info_update(static_info)
+ static_info = self._static_info
+ if event_types := static_info.event_types:
+ self._attr_event_types = event_types
+ self._attr_device_class = try_parse_enum(
+ EventDeviceClass, static_info.device_class
+ )
+
+ @callback
+ def _on_state_update(self) -> None:
+ self._update_state_from_entry_data()
+ self._trigger_event(self._state.event_type)
+ self.async_write_ha_state()
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
index e700dddbb96..cde44fa3231 100644
--- a/homeassistant/components/esphome/manifest.json
+++ b/homeassistant/components/esphome/manifest.json
@@ -15,7 +15,7 @@
"iot_class": "local_push",
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"requirements": [
- "aioesphomeapi==24.1.0",
+ "aioesphomeapi==24.3.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==1.0.0"
],
diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py
index 3e5a82bbd0b..b16a6e798b7 100644
--- a/homeassistant/components/esphome/update.py
+++ b/homeassistant/components/esphome/update.py
@@ -17,7 +17,6 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -149,14 +148,9 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
async def async_added_to_hass(self) -> None:
"""Handle entity added to Home Assistant."""
await super().async_added_to_hass()
- hass = self.hass
entry_data = self._entry_data
self.async_on_remove(
- async_dispatcher_connect(
- hass,
- entry_data.signal_static_info_updated,
- self._handle_device_update,
- )
+ entry_data.async_subscribe_static_info_updated(self._handle_device_update)
)
self.async_on_remove(
entry_data.async_subscribe_device_updated(self._handle_device_update)
diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py
index 3017685a307..4564e863e42 100644
--- a/homeassistant/components/evohome/__init__.py
+++ b/homeassistant/components/evohome/__init__.py
@@ -19,7 +19,10 @@ from evohomeasync2.schema.const import (
SZ_ALLOWED_SYSTEM_MODES,
SZ_AUTO_WITH_RESET,
SZ_CAN_BE_TEMPORARY,
+ SZ_GATEWAY_ID,
+ SZ_GATEWAY_INFO,
SZ_HEAT_SETPOINT,
+ SZ_LOCATION_ID,
SZ_LOCATION_INFO,
SZ_SETPOINT_STATUS,
SZ_STATE_STATUS,
@@ -30,7 +33,7 @@ from evohomeasync2.schema.const import (
SZ_TIMING_MODE,
SZ_UNTIL,
)
-import voluptuous as vol # type: ignore[import-untyped]
+import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -261,14 +264,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return False
if _LOGGER.isEnabledFor(logging.DEBUG):
- _config: dict[str, Any] = {
- SZ_LOCATION_INFO: {SZ_TIME_ZONE: None},
- GWS: [{TCS: None}],
+ loc_info = {
+ SZ_LOCATION_ID: loc_config[SZ_LOCATION_INFO][SZ_LOCATION_ID],
+ SZ_TIME_ZONE: loc_config[SZ_LOCATION_INFO][SZ_TIME_ZONE],
+ }
+ gwy_info = {
+ SZ_GATEWAY_ID: loc_config[GWS][0][SZ_GATEWAY_INFO][SZ_GATEWAY_ID],
+ TCS: loc_config[GWS][0][TCS],
+ }
+ _config = {
+ SZ_LOCATION_INFO: loc_info,
+ GWS: [{SZ_GATEWAY_INFO: gwy_info, TCS: loc_config[GWS][0][TCS]}],
}
- _config[SZ_LOCATION_INFO][SZ_TIME_ZONE] = loc_config[SZ_LOCATION_INFO][
- SZ_TIME_ZONE
- ]
- _config[GWS][0][TCS] = loc_config[GWS][0][TCS]
_LOGGER.debug("Config = %s", _config)
client_v1 = ev1.EvohomeClient(
@@ -455,7 +462,7 @@ class EvoBroker:
self.client.access_token_expires # type: ignore[arg-type]
)
- app_storage = {
+ app_storage: dict[str, Any] = {
CONF_USERNAME: self.client.username,
REFRESH_TOKEN: self.client.refresh_token,
ACCESS_TOKEN: self.client.access_token,
@@ -463,11 +470,11 @@ class EvoBroker:
}
if self.client_v1:
- app_storage[USER_DATA] = { # type: ignore[assignment]
+ app_storage[USER_DATA] = {
SZ_SESSION_ID: self.client_v1.broker.session_id,
} # this is the schema for STORAGE_VER == 1
else:
- app_storage[USER_DATA] = {} # type: ignore[assignment]
+ app_storage[USER_DATA] = {}
await self._store.async_save(app_storage)
diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py
index 0a16e986d0b..2b0c6b77559 100644
--- a/homeassistant/components/feedreader/__init__.py
+++ b/homeassistant/components/feedreader/__init__.py
@@ -117,7 +117,7 @@ class FeedManager:
def _update(self) -> struct_time | None:
"""Update the feed and publish new entries to the event bus."""
_LOGGER.debug("Fetching new data from feed %s", self._url)
- self._feed: feedparser.FeedParserDict = feedparser.parse( # type: ignore[no-redef]
+ self._feed = feedparser.parse(
self._url,
etag=None if not self._feed else self._feed.get("etag"),
modified=None if not self._feed else self._feed.get("modified"),
diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py
index 2c1405130b4..5b7908ddf08 100644
--- a/homeassistant/components/fibaro/__init__.py
+++ b/homeassistant/components/fibaro/__init__.py
@@ -108,26 +108,21 @@ class FibaroController:
# Device infos by fibaro device id
self._device_infos: dict[int, DeviceInfo] = {}
- def connect(self) -> bool:
+ def connect(self) -> None:
"""Start the communication with the Fibaro controller."""
- connected = self._client.connect()
+ # Return value doesn't need to be checked,
+ # it is only relevant when connecting without credentials
+ self._client.connect()
info = self._client.read_info()
self.hub_serial = info.serial_number
self.hub_name = info.hc_name
self.hub_model = info.platform
self.hub_software_version = info.current_version
- if connected is False:
- _LOGGER.error(
- "Invalid login for Fibaro HC. Please check username and password"
- )
- return False
-
self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()}
self._read_devices()
self._scenes = self._client.read_scenes()
- return True
def connect_with_error_handling(self) -> None:
"""Translate connect errors to easily differentiate auth and connect failures.
@@ -135,9 +130,7 @@ class FibaroController:
When there is a better error handling in the used library this can be improved.
"""
try:
- connected = self.connect()
- if not connected:
- raise FibaroConnectFailed("Connect status is false")
+ self.connect()
except HTTPError as http_ex:
if http_ex.response.status_code == 403:
raise FibaroAuthFailed from http_ex
@@ -382,7 +375,7 @@ class FibaroController:
pass
-def _init_controller(data: Mapping[str, Any]) -> FibaroController:
+def init_controller(data: Mapping[str, Any]) -> FibaroController:
"""Validate the user input allows us to connect to fibaro."""
controller = FibaroController(data)
controller.connect_with_error_handling()
@@ -395,7 +388,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
The unique id of the config entry is the serial number of the home center.
"""
try:
- controller = await hass.async_add_executor_job(_init_controller, entry.data)
+ controller = await hass.async_add_executor_job(init_controller, entry.data)
except FibaroConnectFailed as connect_ex:
raise ConfigEntryNotReady(
f"Could not connect to controller at {entry.data[CONF_URL]}"
@@ -454,37 +447,38 @@ class FibaroDevice(Entity):
if not fibaro_device.visible:
self._attr_entity_registry_visible_default = False
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
self.controller.register(self.fibaro_device.fibaro_id, self._update_callback)
- def _update_callback(self):
+ def _update_callback(self) -> None:
"""Update the state."""
self.schedule_update_ha_state(True)
@property
- def level(self):
+ def level(self) -> int | None:
"""Get the level of Fibaro device."""
if self.fibaro_device.value.has_value:
return self.fibaro_device.value.int_value()
return None
@property
- def level2(self):
+ def level2(self) -> int | None:
"""Get the tilt level of Fibaro device."""
if self.fibaro_device.value_2.has_value:
return self.fibaro_device.value_2.int_value()
return None
- def dont_know_message(self, action):
+ def dont_know_message(self, cmd: str) -> None:
"""Make a warning in case we don't know how to perform an action."""
_LOGGER.warning(
- "Not sure how to setValue: %s (available actions: %s)",
+ "Not sure how to %s: %s (available actions: %s)",
+ cmd,
str(self.ha_id),
str(self.fibaro_device.actions),
)
- def set_level(self, level):
+ def set_level(self, level: int) -> None:
"""Set the level of Fibaro device."""
self.action("setValue", level)
if self.fibaro_device.value.has_value:
@@ -492,21 +486,21 @@ class FibaroDevice(Entity):
if self.fibaro_device.has_brightness:
self.fibaro_device.properties["brightness"] = level
- def set_level2(self, level):
+ def set_level2(self, level: int) -> None:
"""Set the level2 of Fibaro device."""
self.action("setValue2", level)
if self.fibaro_device.value_2.has_value:
self.fibaro_device.properties["value2"] = level
- def call_turn_on(self):
+ def call_turn_on(self) -> None:
"""Turn on the Fibaro device."""
self.action("turnOn")
- def call_turn_off(self):
+ def call_turn_off(self) -> None:
"""Turn off the Fibaro device."""
self.action("turnOff")
- def call_set_color(self, red, green, blue, white):
+ def call_set_color(self, red: int, green: int, blue: int, white: int) -> None:
"""Set the color of Fibaro device."""
red = int(max(0, min(255, red)))
green = int(max(0, min(255, green)))
@@ -516,7 +510,7 @@ class FibaroDevice(Entity):
self.fibaro_device.properties["color"] = color_str
self.action("setColor", str(red), str(green), str(blue), str(white))
- def action(self, cmd, *args):
+ def action(self, cmd: str, *args: Any) -> None:
"""Perform an action on the Fibaro HC."""
if cmd in self.fibaro_device.actions:
self.fibaro_device.execute_action(cmd, args)
@@ -525,12 +519,12 @@ class FibaroDevice(Entity):
self.dont_know_message(cmd)
@property
- def current_binary_state(self):
+ def current_binary_state(self) -> bool:
"""Return the current binary state."""
return self.fibaro_device.value.bool_value(False)
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the state attributes of the device."""
attr = {"fibaro_id": self.fibaro_device.fibaro_id}
diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py
index c0980025555..3c965c11b34 100644
--- a/homeassistant/components/fibaro/binary_sensor.py
+++ b/homeassistant/components/fibaro/binary_sensor.py
@@ -76,9 +76,9 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorEntity):
self._attr_icon = SENSOR_TYPES[self._fibaro_sensor_type][1]
@property
- def extra_state_attributes(self) -> Mapping[str, Any] | None:
+ def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the extra state attributes of the device."""
- return super().extra_state_attributes | self._own_extra_state_attributes
+ return {**super().extra_state_attributes, **self._own_extra_state_attributes}
def update(self) -> None:
"""Get the latest data and update the state."""
diff --git a/homeassistant/components/fibaro/config_flow.py b/homeassistant/components/fibaro/config_flow.py
index 8c2fb502488..9003704348d 100644
--- a/homeassistant/components/fibaro/config_flow.py
+++ b/homeassistant/components/fibaro/config_flow.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResu
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from . import FibaroAuthFailed, FibaroConnectFailed, FibaroController
+from . import FibaroAuthFailed, FibaroConnectFailed, init_controller
from .const import CONF_IMPORT_PLUGINS, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -28,19 +28,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
)
-def _connect_to_fibaro(data: dict[str, Any]) -> FibaroController:
- """Validate the user input allows us to connect to fibaro."""
- controller = FibaroController(data)
- controller.connect_with_error_handling()
- return controller
-
-
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
- controller = await hass.async_add_executor_job(_connect_to_fibaro, data)
+ controller = await hass.async_add_executor_job(init_controller, data)
_LOGGER.debug(
"Successfully connected to fibaro home center %s with name %s",
diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py
index 16be6e98ae1..e71ae8982e7 100644
--- a/homeassistant/components/fibaro/cover.py
+++ b/homeassistant/components/fibaro/cover.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, cast
from pyfibaro.fibaro_device import DeviceModel
@@ -80,11 +80,11 @@ class FibaroCover(FibaroDevice, CoverEntity):
def set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
- self.set_level(kwargs.get(ATTR_POSITION))
+ self.set_level(cast(int, kwargs.get(ATTR_POSITION)))
def set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
- self.set_level2(kwargs.get(ATTR_TILT_POSITION))
+ self.set_level2(cast(int, kwargs.get(ATTR_TILT_POSITION)))
@property
def is_closed(self) -> bool | None:
diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json
index bb1558f998b..39850672d06 100644
--- a/homeassistant/components/fibaro/manifest.json
+++ b/homeassistant/components/fibaro/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyfibaro"],
- "requirements": ["pyfibaro==0.7.7"]
+ "requirements": ["pyfibaro==0.7.8"]
}
diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py
index d111fe03c5c..3f0b9e8f6da 100644
--- a/homeassistant/components/folder_watcher/__init__.py
+++ b/homeassistant/components/folder_watcher/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
import os
-from typing import cast
+from typing import Any, cast
import voluptuous as vol
from watchdog.events import (
@@ -19,17 +19,17 @@ from watchdog.events import (
)
from watchdog.observers import Observer
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
+from .const import CONF_FOLDER, CONF_PATTERNS, DEFAULT_PATTERN, DOMAIN
+
_LOGGER = logging.getLogger(__name__)
-CONF_FOLDER = "folder"
-CONF_PATTERNS = "patterns"
-DEFAULT_PATTERN = "*"
-DOMAIN = "folder_watcher"
CONFIG_SCHEMA = vol.Schema(
{
@@ -51,20 +51,62 @@ CONFIG_SCHEMA = vol.Schema(
)
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the folder watcher."""
- conf = config[DOMAIN]
- for watcher in conf:
- path: str = watcher[CONF_FOLDER]
- patterns: list[str] = watcher[CONF_PATTERNS]
- if not hass.config.is_allowed_path(path):
- _LOGGER.error("Folder %s is not valid or allowed", path)
- return False
- Watcher(path, patterns, hass)
+ if DOMAIN in config:
+ conf: list[dict[str, Any]] = config[DOMAIN]
+ for watcher in conf:
+ path: str = watcher[CONF_FOLDER]
+ if not hass.config.is_allowed_path(path):
+ async_create_issue(
+ hass,
+ DOMAIN,
+ f"import_failed_not_allowed_path_{path}",
+ is_fixable=False,
+ is_persistent=False,
+ severity=IssueSeverity.ERROR,
+ translation_key="import_failed_not_allowed_path",
+ translation_placeholders={
+ "path": path,
+ "config_variable": "allowlist_external_dirs",
+ },
+ )
+ continue
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=watcher
+ )
+ )
return True
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Folder watcher from a config entry."""
+
+ path: str = entry.options[CONF_FOLDER]
+ patterns: list[str] = entry.options[CONF_PATTERNS]
+ if not hass.config.is_allowed_path(path):
+ _LOGGER.error("Folder %s is not valid or allowed", path)
+ async_create_issue(
+ hass,
+ DOMAIN,
+ f"setup_not_allowed_path_{path}",
+ is_fixable=False,
+ is_persistent=False,
+ severity=IssueSeverity.ERROR,
+ translation_key="setup_not_allowed_path",
+ translation_placeholders={
+ "path": path,
+ "config_variable": "allowlist_external_dirs",
+ },
+ learn_more_url="https://www.home-assistant.io/docs/configuration/basic/#allowlist_external_dirs",
+ )
+ return False
+ await hass.async_add_executor_job(Watcher, path, patterns, hass)
+ return True
+
+
def create_event_handler(patterns: list[str], hass: HomeAssistant) -> EventHandler:
"""Return the Watchdog EventHandler object."""
diff --git a/homeassistant/components/folder_watcher/config_flow.py b/homeassistant/components/folder_watcher/config_flow.py
new file mode 100644
index 00000000000..50d198df3c3
--- /dev/null
+++ b/homeassistant/components/folder_watcher/config_flow.py
@@ -0,0 +1,116 @@
+"""Adds config flow for Folder watcher."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import os
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN
+from homeassistant.config_entries import ConfigFlowResult
+from homeassistant.core import callback
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
+from homeassistant.helpers.schema_config_entry_flow import (
+ SchemaCommonFlowHandler,
+ SchemaConfigFlowHandler,
+ SchemaFlowError,
+ SchemaFlowFormStep,
+)
+from homeassistant.helpers.selector import (
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+ TextSelector,
+)
+
+from .const import CONF_FOLDER, CONF_PATTERNS, DEFAULT_PATTERN, DOMAIN
+
+
+async def validate_setup(
+ handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
+) -> dict[str, Any]:
+ """Check path is a folder."""
+ value: str = user_input[CONF_FOLDER]
+ dir_in = os.path.expanduser(str(value))
+ handler.parent_handler._async_abort_entries_match({CONF_FOLDER: value}) # pylint: disable=protected-access
+
+ if not os.path.isdir(dir_in):
+ raise SchemaFlowError("not_dir")
+ if not os.access(dir_in, os.R_OK):
+ raise SchemaFlowError("not_readable_dir")
+ if not handler.parent_handler.hass.config.is_allowed_path(value):
+ raise SchemaFlowError("not_allowed_dir")
+
+ return user_input
+
+
+async def validate_import_setup(
+ handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
+) -> dict[str, Any]:
+ """Create issue on successful import."""
+ async_create_issue(
+ handler.parent_handler.hass,
+ HOMEASSISTANT_DOMAIN,
+ f"deprecated_yaml_{DOMAIN}",
+ breaks_in_ha_version="2024.11.0",
+ is_fixable=False,
+ is_persistent=False,
+ issue_domain=DOMAIN,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "Folder Watcher",
+ },
+ )
+ return user_input
+
+
+OPTIONS_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_PATTERNS, default=[DEFAULT_PATTERN]): SelectSelector(
+ SelectSelectorConfig(
+ options=[DEFAULT_PATTERN],
+ multiple=True,
+ custom_value=True,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ ),
+ }
+)
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_FOLDER): TextSelector(),
+ }
+).extend(OPTIONS_SCHEMA.schema)
+
+CONFIG_FLOW = {
+ "user": SchemaFlowFormStep(schema=DATA_SCHEMA, validate_user_input=validate_setup),
+ "import": SchemaFlowFormStep(
+ schema=DATA_SCHEMA, validate_user_input=validate_import_setup
+ ),
+}
+OPTIONS_FLOW = {
+ "init": SchemaFlowFormStep(schema=OPTIONS_SCHEMA),
+}
+
+
+class FolderWatcherConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
+ """Handle a config flow for Folder Watcher."""
+
+ config_flow = CONFIG_FLOW
+ options_flow = OPTIONS_FLOW
+
+ def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
+ """Return config entry title."""
+ return f"Folder Watcher {options[CONF_FOLDER]}"
+
+ @callback
+ def async_create_entry(
+ self, data: Mapping[str, Any], **kwargs: Any
+ ) -> ConfigFlowResult:
+ """Finish config flow and create a config entry."""
+ self._async_abort_entries_match({CONF_FOLDER: data[CONF_FOLDER]})
+ return super().async_create_entry(data, **kwargs)
diff --git a/homeassistant/components/folder_watcher/const.py b/homeassistant/components/folder_watcher/const.py
new file mode 100644
index 00000000000..22dae3b9164
--- /dev/null
+++ b/homeassistant/components/folder_watcher/const.py
@@ -0,0 +1,6 @@
+"""Constants for Folder watcher."""
+
+CONF_FOLDER = "folder"
+CONF_PATTERNS = "patterns"
+DEFAULT_PATTERN = "*"
+DOMAIN = "folder_watcher"
diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json
index 96decd0b8cf..7b471e08fcc 100644
--- a/homeassistant/components/folder_watcher/manifest.json
+++ b/homeassistant/components/folder_watcher/manifest.json
@@ -2,6 +2,7 @@
"domain": "folder_watcher",
"name": "Folder Watcher",
"codeowners": [],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/folder_watcher",
"iot_class": "local_polling",
"loggers": ["watchdog"],
diff --git a/homeassistant/components/folder_watcher/strings.json b/homeassistant/components/folder_watcher/strings.json
new file mode 100644
index 00000000000..bd1742b8ce3
--- /dev/null
+++ b/homeassistant/components/folder_watcher/strings.json
@@ -0,0 +1,46 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ },
+ "error": {
+ "not_dir": "Configured path is not a directory",
+ "not_readable_dir": "Configured path is not readable",
+ "not_allowed_dir": "Configured path is not in allowlist"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "folder": "Path to the watched folder",
+ "patterns": "Pattern(s) to monitor"
+ },
+ "data_description": {
+ "folder": "Path needs to be from root, as example `/config`",
+ "patterns": "Example: `*.yaml` to only see yaml files"
+ }
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "patterns": "[%key:component::folder_watcher::config::step::user::data::patterns%]"
+ },
+ "data_description": {
+ "patterns": "[%key:component::folder_watcher::config::step::user::data_description::patterns%]"
+ }
+ }
+ }
+ },
+ "issues": {
+ "import_failed_not_allowed_path": {
+ "title": "The Folder Watcher YAML configuration could not be imported",
+ "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in config.yaml and restart Home Assistant to import it and fix this issue."
+ },
+ "setup_not_allowed_path": {
+ "title": "The Folder Watcher configuration for {path} could not start",
+ "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in config.yaml and restart Home Assistant to fix this issue."
+ }
+ }
+}
diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py
index ba9e2191901..bab97569eda 100644
--- a/homeassistant/components/fritz/__init__.py
+++ b/homeassistant/components/fritz/__init__.py
@@ -3,13 +3,20 @@
import logging
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_SSL,
+ CONF_USERNAME,
+)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .common import AvmWrapper, FritzData
from .const import (
DATA_FRITZ,
+ DEFAULT_SSL,
DOMAIN,
FRITZ_AUTH_EXCEPTIONS,
FRITZ_EXCEPTIONS,
@@ -29,6 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
port=entry.data[CONF_PORT],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
+ use_tls=entry.data.get(CONF_SSL, DEFAULT_SSL),
)
try:
diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py
index e4d5e92b742..f051c824847 100644
--- a/homeassistant/components/fritz/common.py
+++ b/homeassistant/components/fritz/common.py
@@ -48,7 +48,7 @@ from .const import (
DEFAULT_CONF_OLD_DISCOVERY,
DEFAULT_DEVICE_NAME,
DEFAULT_HOST,
- DEFAULT_PORT,
+ DEFAULT_SSL,
DEFAULT_USERNAME,
DOMAIN,
FRITZ_EXCEPTIONS,
@@ -184,9 +184,10 @@ class FritzBoxTools(
self,
hass: HomeAssistant,
password: str,
+ port: int,
username: str = DEFAULT_USERNAME,
host: str = DEFAULT_HOST,
- port: int = DEFAULT_PORT,
+ use_tls: bool = DEFAULT_SSL,
) -> None:
"""Initialize FritzboxTools class."""
super().__init__(
@@ -211,6 +212,7 @@ class FritzBoxTools(
self.password = password
self.port = port
self.username = username
+ self.use_tls = use_tls
self.has_call_deflections: bool = False
self._model: str | None = None
self._current_firmware: str | None = None
@@ -230,11 +232,13 @@ class FritzBoxTools(
def setup(self) -> None:
"""Set up FritzboxTools class."""
+
self.connection = FritzConnection(
address=self.host,
port=self.port,
user=self.username,
password=self.password,
+ use_tls=self.use_tls,
timeout=60.0,
pool_maxsize=30,
)
diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py
index a217adf935c..fdafd486b29 100644
--- a/homeassistant/components/fritz/config_flow.py
+++ b/homeassistant/components/fritz/config_flow.py
@@ -25,14 +25,22 @@ from homeassistant.config_entries import (
OptionsFlow,
OptionsFlowWithConfigEntry,
)
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_SSL,
+ CONF_USERNAME,
+)
from homeassistant.core import callback
from .const import (
CONF_OLD_DISCOVERY,
DEFAULT_CONF_OLD_DISCOVERY,
DEFAULT_HOST,
- DEFAULT_PORT,
+ DEFAULT_HTTP_PORT,
+ DEFAULT_HTTPS_PORT,
+ DEFAULT_SSL,
DOMAIN,
ERROR_AUTH_INVALID,
ERROR_CANNOT_CONNECT,
@@ -61,6 +69,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
self._entry: ConfigEntry | None = None
self._name: str = ""
self._password: str = ""
+ self._use_tls: bool = False
self._port: int | None = None
self._username: str = ""
self._model: str = ""
@@ -74,6 +83,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
port=self._port,
user=self._username,
password=self._password,
+ use_tls=self._use_tls,
timeout=60.0,
pool_maxsize=30,
)
@@ -120,6 +130,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PASSWORD: self._password,
CONF_PORT: self._port,
CONF_USERNAME: self._username,
+ CONF_SSL: self._use_tls,
},
options={
CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME.total_seconds(),
@@ -127,13 +138,18 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
},
)
+ def _determine_port(self, user_input: dict[str, Any]) -> int:
+ """Determine port from user_input."""
+ if port := user_input.get(CONF_PORT):
+ return int(port)
+ return DEFAULT_HTTPS_PORT if user_input[CONF_SSL] else DEFAULT_HTTP_PORT
+
async def async_step_ssdp(
self, discovery_info: ssdp.SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "")
self._host = ssdp_location.hostname
- self._port = ssdp_location.port
self._name = (
discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME]
@@ -178,6 +194,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
+ self._use_tls = user_input[CONF_SSL]
+ self._port = self._determine_port(user_input)
error = await self.hass.async_add_executor_job(self.fritz_tools_init)
@@ -191,14 +209,22 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
self, errors: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Show the setup form to the user."""
+
+ advanced_data_schema = {}
+ if self.show_advanced_options:
+ advanced_data_schema = {
+ vol.Optional(CONF_PORT): vol.Coerce(int),
+ }
+
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional(CONF_HOST, default=DEFAULT_HOST): str,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int),
+ **advanced_data_schema,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
}
),
errors=errors or {},
@@ -214,6 +240,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
}
),
description_placeholders={"name": self._name},
@@ -227,9 +254,11 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self._show_setup_form_init()
self._host = user_input[CONF_HOST]
- self._port = user_input[CONF_PORT]
self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
+ self._use_tls = user_input[CONF_SSL]
+
+ self._port = self._determine_port(user_input)
if not (error := await self.hass.async_add_executor_job(self.fritz_tools_init)):
self._name = self._model
@@ -251,6 +280,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
self._port = entry_data[CONF_PORT]
self._username = entry_data[CONF_USERNAME]
self._password = entry_data[CONF_PASSWORD]
+ self._use_tls = entry_data[CONF_SSL]
+
return await self.async_step_reauth_confirm()
def _show_setup_form_reauth_confirm(
@@ -295,11 +326,83 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PASSWORD: self._password,
CONF_PORT: self._port,
CONF_USERNAME: self._username,
+ CONF_SSL: self._use_tls,
},
)
await self.hass.config_entries.async_reload(self._entry.entry_id)
return self.async_abort(reason="reauth_successful")
+ async def async_step_reconfigure(self, _: Mapping[str, Any]) -> ConfigFlowResult:
+ """Handle reconfigure flow ."""
+ self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
+ assert self._entry
+ self._host = self._entry.data[CONF_HOST]
+ self._port = self._entry.data[CONF_PORT]
+ self._username = self._entry.data[CONF_USERNAME]
+ self._password = self._entry.data[CONF_PASSWORD]
+ self._use_tls = self._entry.data.get(CONF_SSL, DEFAULT_SSL)
+
+ return await self.async_step_reconfigure_confirm()
+
+ def _show_setup_form_reconfigure_confirm(
+ self, user_input: dict[str, Any], errors: dict[str, str] | None = None
+ ) -> ConfigFlowResult:
+ """Show the reconfigure form to the user."""
+ advanced_data_schema = {}
+ if self.show_advanced_options:
+ advanced_data_schema = {
+ vol.Optional(CONF_PORT, default=user_input[CONF_PORT]): vol.Coerce(int),
+ }
+
+ return self.async_show_form(
+ step_id="reconfigure_confirm",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str,
+ **advanced_data_schema,
+ vol.Required(CONF_SSL, default=user_input[CONF_SSL]): bool,
+ }
+ ),
+ description_placeholders={"host": self._host},
+ errors=errors or {},
+ )
+
+ async def async_step_reconfigure_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfigure flow."""
+ if user_input is None:
+ return self._show_setup_form_reconfigure_confirm(
+ {
+ CONF_HOST: self._host,
+ CONF_PORT: self._port,
+ CONF_SSL: self._use_tls,
+ }
+ )
+
+ self._host = user_input[CONF_HOST]
+ self._use_tls = user_input[CONF_SSL]
+ self._port = self._determine_port(user_input)
+
+ if error := await self.hass.async_add_executor_job(self.fritz_tools_init):
+ return self._show_setup_form_reconfigure_confirm(
+ user_input={**user_input, CONF_PORT: self._port}, errors={"base": error}
+ )
+
+ assert isinstance(self._entry, ConfigEntry)
+ self.hass.config_entries.async_update_entry(
+ self._entry,
+ data={
+ CONF_HOST: self._host,
+ CONF_PASSWORD: self._password,
+ CONF_PORT: self._port,
+ CONF_USERNAME: self._username,
+ CONF_SSL: self._use_tls,
+ },
+ )
+ await self.hass.config_entries.async_reload(self._entry.entry_id)
+ return self.async_abort(reason="reconfigure_successful")
+
class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Handle an options flow."""
diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py
index caa7d44c378..3794a83dd7f 100644
--- a/homeassistant/components/fritz/const.py
+++ b/homeassistant/components/fritz/const.py
@@ -46,8 +46,10 @@ DSL_CONNECTION: Literal["dsl"] = "dsl"
DEFAULT_DEVICE_NAME = "Unknown device"
DEFAULT_HOST = "192.168.178.1"
-DEFAULT_PORT = 49000
+DEFAULT_HTTP_PORT = 49000
+DEFAULT_HTTPS_PORT = 49443
DEFAULT_USERNAME = ""
+DEFAULT_SSL = False
ERROR_AUTH_INVALID = "invalid_auth"
ERROR_CANNOT_CONNECT = "cannot_connect"
diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json
index 5eed2f59fc4..a96c3b8ac28 100644
--- a/homeassistant/components/fritz/strings.json
+++ b/homeassistant/components/fritz/strings.json
@@ -18,6 +18,19 @@
"password": "[%key:common::config_flow::data::password%]"
}
},
+ "reconfigure_confirm": {
+ "title": "Updating FRITZ!Box Tools - configuration",
+ "description": "Update FRITZ!Box Tools configuration for: {host}.",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]",
+ "ssl": "[%key:common::config_flow::data::ssl%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP address of your FRITZ!Box router.",
+ "port": "Leave it empty to use the default port."
+ }
+ },
"user": {
"title": "[%key:component::fritz::config::step::confirm::title%]",
"description": "Set up FRITZ!Box Tools to control your FRITZ!Box.\nMinimum needed: username, password.",
@@ -25,10 +38,12 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]",
- "password": "[%key:common::config_flow::data::password%]"
+ "password": "[%key:common::config_flow::data::password%]",
+ "ssl": "[%key:common::config_flow::data::ssl%]"
},
"data_description": {
- "host": "The hostname or IP address of your FRITZ!Box router."
+ "host": "The hostname or IP address of your FRITZ!Box router.",
+ "port": "Leave it empty to use the default port."
}
}
},
@@ -36,7 +51,8 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"ignore_ip6_link_local": "IPv6 link local address is not supported.",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py
index 7f4006768c4..904a86d21ae 100644
--- a/homeassistant/components/fritzbox/__init__.py
+++ b/homeassistant/components/fritzbox/__init__.py
@@ -51,12 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
has_templates = await hass.async_add_executor_job(fritz.has_templates)
LOGGER.debug("enable smarthome templates: %s", has_templates)
- coordinator = FritzboxDataUpdateCoordinator(hass, entry, has_templates)
-
- await coordinator.async_config_entry_first_refresh()
-
- hass.data[DOMAIN][entry.entry_id][CONF_COORDINATOR] = coordinator
-
def _update_unique_id(entry: RegistryEntry) -> dict[str, str] | None:
"""Update unique ID of entity entry."""
if (
@@ -79,6 +73,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await async_migrate_entries(hass, entry.entry_id, _update_unique_id)
+ coordinator = FritzboxDataUpdateCoordinator(hass, entry.entry_id, has_templates)
+ await coordinator.async_setup()
+ hass.data[DOMAIN][entry.entry_id][CONF_COORDINATOR] = coordinator
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
def logout_fritzbox(event: Event) -> None:
diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py
index c89415fa7ee..62f189b542f 100644
--- a/homeassistant/components/fritzbox/config_flow.py
+++ b/homeassistant/components/fritzbox/config_flow.py
@@ -221,3 +221,44 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"name": self._name},
errors=errors,
)
+
+ async def async_step_reconfigure(
+ self, _: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a reconfiguration flow initialized by the user."""
+ entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
+ assert entry is not None
+ self._entry = entry
+ self._name = self._entry.data[CONF_HOST]
+ self._host = self._entry.data[CONF_HOST]
+ self._username = self._entry.data[CONF_USERNAME]
+ self._password = self._entry.data[CONF_PASSWORD]
+
+ return await self.async_step_reconfigure_confirm()
+
+ async def async_step_reconfigure_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a reconfiguration flow initialized by the user."""
+ errors = {}
+
+ if user_input is not None:
+ self._host = user_input[CONF_HOST]
+
+ result = await self.hass.async_add_executor_job(self._try_connect)
+
+ if result == RESULT_SUCCESS:
+ await self._update_entry()
+ return self.async_abort(reason="reconfigure_successful")
+ errors["base"] = result
+
+ return self.async_show_form(
+ step_id="reconfigure_confirm",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_HOST, default=self._host): str,
+ }
+ ),
+ description_placeholders={"name": self._name},
+ errors=errors,
+ )
diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py
index c58665f2b5d..54af8fbdacd 100644
--- a/homeassistant/components/fritzbox/coordinator.py
+++ b/homeassistant/components/fritzbox/coordinator.py
@@ -12,6 +12,7 @@ from requests.exceptions import ConnectionError as RequestConnectionError, HTTPE
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_CONNECTIONS, DOMAIN, LOGGER
@@ -28,34 +29,62 @@ class FritzboxCoordinatorData:
class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorData]):
"""Fritzbox Smarthome device data update coordinator."""
+ config_entry: ConfigEntry
configuration_url: str
- def __init__(
- self, hass: HomeAssistant, entry: ConfigEntry, has_templates: bool
- ) -> None:
+ def __init__(self, hass: HomeAssistant, name: str, has_templates: bool) -> None:
"""Initialize the Fritzbox Smarthome device coordinator."""
- self.entry = entry
- self.fritz: Fritzhome = hass.data[DOMAIN][self.entry.entry_id][CONF_CONNECTIONS]
+ super().__init__(
+ hass,
+ LOGGER,
+ name=name,
+ update_interval=timedelta(seconds=30),
+ )
+
+ self.fritz: Fritzhome = hass.data[DOMAIN][self.config_entry.entry_id][
+ CONF_CONNECTIONS
+ ]
self.configuration_url = self.fritz.get_prefixed_host()
self.has_templates = has_templates
self.new_devices: set[str] = set()
self.new_templates: set[str] = set()
- super().__init__(
- hass,
- LOGGER,
- name=entry.entry_id,
- update_interval=timedelta(seconds=30),
+ self.data = FritzboxCoordinatorData({}, {})
+
+ async def async_setup(self) -> None:
+ """Set up the coordinator."""
+ await self.async_config_entry_first_refresh()
+ self.cleanup_removed_devices(
+ list(self.data.devices) + list(self.data.templates)
)
- self.data = FritzboxCoordinatorData({}, {})
+ def cleanup_removed_devices(self, available_ains: list[str]) -> None:
+ """Cleanup entity and device registry from removed devices."""
+ entity_reg = er.async_get(self.hass)
+ for entity in er.async_entries_for_config_entry(
+ entity_reg, self.config_entry.entry_id
+ ):
+ if entity.unique_id.split("_")[0] not in available_ains:
+ LOGGER.debug("Removing obsolete entity entry %s", entity.entity_id)
+ entity_reg.async_remove(entity.entity_id)
+
+ device_reg = dr.async_get(self.hass)
+ identifiers = {(DOMAIN, ain) for ain in available_ains}
+ for device in dr.async_entries_for_config_entry(
+ device_reg, self.config_entry.entry_id
+ ):
+ if not set(device.identifiers) & identifiers:
+ LOGGER.debug("Removing obsolete device entry %s", device.name)
+ device_reg.async_update_device(
+ device.id, remove_config_entry_id=self.config_entry.entry_id
+ )
def _update_fritz_devices(self) -> FritzboxCoordinatorData:
"""Update all fritzbox device data."""
try:
- self.fritz.update_devices()
+ self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
- self.fritz.update_templates()
+ self.fritz.update_templates(ignore_removed=False)
except RequestConnectionError as ex:
raise UpdateFailed from ex
except HTTPError:
@@ -64,9 +93,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.fritz.login()
except LoginError as ex:
raise ConfigEntryAuthFailed from ex
- self.fritz.update_devices()
+ self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
- self.fritz.update_templates()
+ self.fritz.update_templates(ignore_removed=False)
devices = self.fritz.get_devices()
device_data = {}
@@ -99,4 +128,14 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
async def _async_update_data(self) -> FritzboxCoordinatorData:
"""Fetch all device data."""
- return await self.hass.async_add_executor_job(self._update_fritz_devices)
+ new_data = await self.hass.async_add_executor_job(self._update_fritz_devices)
+
+ if (
+ self.data.devices.keys() - new_data.devices.keys()
+ or self.data.templates.keys() - new_data.templates.keys()
+ ):
+ self.cleanup_removed_devices(
+ list(new_data.devices) + list(new_data.templates)
+ )
+
+ return new_data
diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json
index 5d41f8c12dc..de2e9e0200a 100644
--- a/homeassistant/components/fritzbox/manifest.json
+++ b/homeassistant/components/fritzbox/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyfritzhome"],
"quality_scale": "gold",
- "requirements": ["pyfritzhome==0.6.10"],
+ "requirements": ["pyfritzhome==0.6.11"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"
diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json
index f4d2fe3670e..755cc97d7d8 100644
--- a/homeassistant/components/fritzbox/strings.json
+++ b/homeassistant/components/fritzbox/strings.json
@@ -26,6 +26,15 @@
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
+ },
+ "reconfigure_confirm": {
+ "description": "Update your configuration information for {name}.",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP address of your FRITZ!Box router."
+ }
}
},
"abort": {
@@ -34,7 +43,8 @@
"ignore_ip6_link_local": "IPv6 link local address is not supported.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"not_supported": "Connected to AVM FRITZ!Box but it's unable to control Smart Home devices.",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index d711314cabb..ad63bdbed84 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20240404.2"]
+ "requirements": ["home-assistant-frontend==20240424.1"]
}
diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py
index 6c3b5223ef9..f17560ebc62 100644
--- a/homeassistant/components/geniushub/water_heater.py
+++ b/homeassistant/components/geniushub/water_heater.py
@@ -75,9 +75,9 @@ class GeniusWaterHeater(GeniusHeatingZone, WaterHeaterEntity):
return list(HA_OPMODE_TO_GH)
@property
- def current_operation(self) -> str:
+ def current_operation(self) -> str | None:
"""Return the current operation mode."""
- return GH_STATE_TO_HA[self._zone.data["mode"]] # type: ignore[return-value]
+ return GH_STATE_TO_HA[self._zone.data["mode"]]
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set a new operation mode for this boiler."""
diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json
index 2e33bc6741e..b509806d07f 100644
--- a/homeassistant/components/gios/manifest.json
+++ b/homeassistant/components/gios/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["dacite", "gios"],
"quality_scale": "platinum",
- "requirements": ["gios==3.2.2"]
+ "requirements": ["gios==4.0.0"]
}
diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json
index 03575f9f4e2..6f1bdd2b449 100644
--- a/homeassistant/components/goodwe/manifest.json
+++ b/homeassistant/components/goodwe/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/goodwe",
"iot_class": "local_polling",
"loggers": ["goodwe"],
- "requirements": ["goodwe==0.2.32"]
+ "requirements": ["goodwe==0.3.2"]
}
diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json
index 00561cb5fd6..ac43dc58953 100644
--- a/homeassistant/components/google/manifest.json
+++ b/homeassistant/components/google/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/calendar.google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
- "requirements": ["gcal-sync==6.0.4", "oauth2client==4.1.3", "ical==7.0.3"]
+ "requirements": ["gcal-sync==6.0.4", "oauth2client==4.1.3", "ical==8.0.0"]
}
diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py
index b62bd0fe5a2..29a1b20f2bc 100644
--- a/homeassistant/components/google_tasks/__init__.py
+++ b/homeassistant/components/google_tasks/__init__.py
@@ -2,12 +2,12 @@
from __future__ import annotations
-from aiohttp import ClientError
+from aiohttp import ClientError, ClientResponseError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow
from . import api
@@ -18,8 +18,6 @@ PLATFORMS: list[Platform] = [Platform.TODO]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google Tasks from a config entry."""
- hass.data.setdefault(DOMAIN, {})
-
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
@@ -29,10 +27,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
auth = api.AsyncConfigEntryAuth(hass, session)
try:
await auth.async_get_access_token()
+ except ClientResponseError as err:
+ if 400 <= err.status < 500:
+ raise ConfigEntryAuthFailed(
+ "OAuth session is not valid, reauth required"
+ ) from err
+ raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
- hass.data[DOMAIN][entry.entry_id] = auth
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = auth
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/google_tasks/config_flow.py b/homeassistant/components/google_tasks/config_flow.py
index a8e283b55c8..a9ef5c7ff23 100644
--- a/homeassistant/components/google_tasks/config_flow.py
+++ b/homeassistant/components/google_tasks/config_flow.py
@@ -1,5 +1,6 @@
"""Config flow for Google Tasks."""
+from collections.abc import Mapping
import logging
from typing import Any
@@ -8,7 +9,7 @@ from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from googleapiclient.http import HttpRequest
-from homeassistant.config_entries import ConfigFlowResult
+from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow
@@ -22,6 +23,8 @@ class OAuth2FlowHandler(
DOMAIN = DOMAIN
+ reauth_entry: ConfigEntry | None = None
+
@property
def logger(self) -> logging.Logger:
"""Return logger."""
@@ -39,11 +42,21 @@ class OAuth2FlowHandler(
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow."""
+ credentials = Credentials(token=data[CONF_TOKEN][CONF_ACCESS_TOKEN])
try:
+ user_resource = build(
+ "oauth2",
+ "v2",
+ credentials=credentials,
+ )
+ user_resource_cmd: HttpRequest = user_resource.userinfo().get()
+ user_resource_info = await self.hass.async_add_executor_job(
+ user_resource_cmd.execute
+ )
resource = build(
"tasks",
"v1",
- credentials=Credentials(token=data[CONF_TOKEN][CONF_ACCESS_TOKEN]),
+ credentials=credentials,
)
cmd: HttpRequest = resource.tasklists().list()
await self.hass.async_add_executor_job(cmd.execute)
@@ -56,4 +69,32 @@ class OAuth2FlowHandler(
except Exception: # pylint: disable=broad-except
self.logger.exception("Unknown error occurred")
return self.async_abort(reason="unknown")
- return self.async_create_entry(title=self.flow_impl.name, data=data)
+ user_id = user_resource_info["id"]
+ if not self.reauth_entry:
+ await self.async_set_unique_id(user_id)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title=user_resource_info["name"], data=data)
+
+ if self.reauth_entry.unique_id == user_id or not self.reauth_entry.unique_id:
+ return self.async_update_reload_and_abort(
+ self.reauth_entry, unique_id=user_id, data=data
+ )
+
+ return self.async_abort(reason="wrong_account")
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon an API authentication error."""
+ self.reauth_entry = self.hass.config_entries.async_get_entry(
+ self.context["entry_id"]
+ )
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm reauth dialog."""
+ if user_input is None:
+ return self.async_show_form(step_id="reauth_confirm")
+ return await self.async_step_user()
diff --git a/homeassistant/components/google_tasks/const.py b/homeassistant/components/google_tasks/const.py
index 87253486127..0cb04bf1d4e 100644
--- a/homeassistant/components/google_tasks/const.py
+++ b/homeassistant/components/google_tasks/const.py
@@ -6,7 +6,10 @@ DOMAIN = "google_tasks"
OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth"
OAUTH2_TOKEN = "https://oauth2.googleapis.com/token"
-OAUTH2_SCOPES = ["https://www.googleapis.com/auth/tasks"]
+OAUTH2_SCOPES = [
+ "https://www.googleapis.com/auth/tasks",
+ "https://www.googleapis.com/auth/userinfo.profile",
+]
class TaskStatus(StrEnum):
diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json
index 2cf15f0d93d..4479b34935e 100644
--- a/homeassistant/components/google_tasks/strings.json
+++ b/homeassistant/components/google_tasks/strings.json
@@ -18,6 +18,7 @@
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"access_not_configured": "Unable to access the Google API:\n\n{message}",
"unknown": "[%key:common::config_flow::error::unknown%]",
+ "wrong_account": "Wrong account: Please authenticate with the right account.",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]"
diff --git a/homeassistant/components/google_translate/const.py b/homeassistant/components/google_translate/const.py
index 76827606816..68d8208f26b 100644
--- a/homeassistant/components/google_translate/const.py
+++ b/homeassistant/components/google_translate/const.py
@@ -7,8 +7,25 @@ DEFAULT_LANG = "en"
DEFAULT_TLD = "com"
DOMAIN = "google_translate"
+# INSTRUCTIONS TO UPDATE LIST:
+#
+# Removal:
+# Removal is as simple as deleting the line containing the language code no longer
+# supported.
+#
+# Addition:
+# In order to add to this list, follow the below steps:
+# 1. Find out if the language is supported: Go to Google Translate website and try
+# translating any word from English into your desired language.
+# If the "speech" icon is grayed out or no speech is generated, the language is
+# not supported and cannot be added. Otherwise, proceed:
+# 2. Grab the language code from https://cloud.google.com/translate/docs/languages
+# 3. Add the language code in SUPPORT_LANGUAGES, making sure to not disturb the
+# alphabetical nature of the list.
+
SUPPORT_LANGUAGES = [
"af",
+ "am",
"ar",
"bg",
"bn",
@@ -20,16 +37,18 @@ SUPPORT_LANGUAGES = [
"de",
"el",
"en",
- "eo",
"es",
"et",
+ "eu",
"fi",
+ "fil",
"fr",
+ "gl",
"gu",
+ "ha",
"hi",
"hr",
"hu",
- "hy",
"id",
"is",
"it",
@@ -40,15 +59,16 @@ SUPPORT_LANGUAGES = [
"kn",
"ko",
"la",
- "lv",
"lt",
- "mk",
+ "lv",
"ml",
"mr",
+ "ms",
"my",
"ne",
"nl",
"no",
+ "pa",
"pl",
"pt",
"ro",
diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json
index 64feedc44c1..98b802f8233 100644
--- a/homeassistant/components/govee_ble/manifest.json
+++ b/homeassistant/components/govee_ble/manifest.json
@@ -90,5 +90,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
"iot_class": "local_push",
- "requirements": ["govee-ble==0.31.0"]
+ "requirements": ["govee-ble==0.31.2"]
}
diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py
index bad3d7944d3..425dcf5a914 100644
--- a/homeassistant/components/group/notify.py
+++ b/homeassistant/components/group/notify.py
@@ -34,12 +34,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def add_defaults(
- input_data: dict[str, Any], default_data: dict[str, Any]
+ input_data: dict[str, Any], default_data: Mapping[str, Any]
) -> dict[str, Any]:
"""Deep update a dictionary with default values."""
for key, val in default_data.items():
if isinstance(val, Mapping):
- input_data[key] = add_defaults(input_data.get(key, {}), val) # type: ignore[arg-type]
+ input_data[key] = add_defaults(input_data.get(key, {}), val)
elif key not in input_data:
input_data[key] = val
return input_data
diff --git a/homeassistant/components/group/registry.py b/homeassistant/components/group/registry.py
index 1441d39d331..6cdb929d60c 100644
--- a/homeassistant/components/group/registry.py
+++ b/homeassistant/components/group/registry.py
@@ -47,10 +47,12 @@ def _process_group_platform(
class GroupIntegrationRegistry:
"""Class to hold a registry of integrations."""
- on_off_mapping: dict[str, str] = {STATE_ON: STATE_OFF}
- off_on_mapping: dict[str, str] = {STATE_OFF: STATE_ON}
- on_states_by_domain: dict[str, set] = {}
- exclude_domains: set = set()
+ def __init__(self) -> None:
+ """Imitialize registry."""
+ self.on_off_mapping: dict[str, str] = {STATE_ON: STATE_OFF}
+ self.off_on_mapping: dict[str, str] = {STATE_OFF: STATE_ON}
+ self.on_states_by_domain: dict[str, set[str]] = {}
+ self.exclude_domains: set[str] = set()
def exclude_domain(self) -> None:
"""Exclude the current domain."""
diff --git a/homeassistant/components/harmony/entity.py b/homeassistant/components/harmony/entity.py
index 99b5744e0ed..8bfa9fbad4d 100644
--- a/homeassistant/components/harmony/entity.py
+++ b/homeassistant/components/harmony/entity.py
@@ -6,6 +6,7 @@ from collections.abc import Callable
from datetime import datetime
import logging
+from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_call_later
@@ -38,7 +39,7 @@ class HarmonyEntity(Entity):
_LOGGER.debug("%s: connected to the HUB", self._data.name)
self.async_write_ha_state()
- self._clear_disconnection_delay()
+ self._async_clear_disconnection_delay()
async def async_got_disconnected(self, _: str | None = None) -> None:
"""Notification that we're disconnected from the HUB."""
@@ -46,15 +47,19 @@ class HarmonyEntity(Entity):
# We're going to wait for 10 seconds before announcing we're
# unavailable, this to allow a reconnection to happen.
self._unsub_mark_disconnected = async_call_later(
- self.hass, TIME_MARK_DISCONNECTED, self._mark_disconnected_if_unavailable
+ self.hass,
+ TIME_MARK_DISCONNECTED,
+ self._async_mark_disconnected_if_unavailable,
)
- def _clear_disconnection_delay(self) -> None:
+ @callback
+ def _async_clear_disconnection_delay(self) -> None:
if self._unsub_mark_disconnected:
self._unsub_mark_disconnected()
self._unsub_mark_disconnected = None
- def _mark_disconnected_if_unavailable(self, _: datetime) -> None:
+ @callback
+ def _async_mark_disconnected_if_unavailable(self, _: datetime) -> None:
self._unsub_mark_disconnected = None
if not self.available:
# Still disconnected. Let the state engine know.
diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py
index c6b2e9be718..0c9bdcb9c6e 100644
--- a/homeassistant/components/harmony/remote.py
+++ b/homeassistant/components/harmony/remote.py
@@ -138,7 +138,7 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity):
_LOGGER.debug("%s: Harmony Hub added", self._data.name)
- self.async_on_remove(self._clear_disconnection_delay)
+ self.async_on_remove(self._async_clear_disconnection_delay)
self._setup_callbacks()
self.async_on_remove(
diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json
index 5a1edcd3c3f..3494798b50b 100644
--- a/homeassistant/components/holiday/manifest.json
+++ b/homeassistant/components/holiday/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
- "requirements": ["holidays==0.46", "babel==2.13.1"]
+ "requirements": ["holidays==0.47", "babel==2.13.1"]
}
diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json
index d46a2e50bfd..09b2f17c947 100644
--- a/homeassistant/components/homeassistant/strings.json
+++ b/homeassistant/components/homeassistant/strings.json
@@ -192,7 +192,7 @@
"service_not_found": {
"message": "Service {domain}.{service} not found."
},
- "service_does_not_supports_reponse": {
+ "service_does_not_support_response": {
"message": "A service which does not return responses can't be called with {return_response}."
},
"service_lacks_response_request": {
diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py
index 7dcd9f8db97..ef5e330699a 100644
--- a/homeassistant/components/homeassistant_alerts/__init__.py
+++ b/homeassistant/components/homeassistant_alerts/__init__.py
@@ -20,7 +20,7 @@ from homeassistant.helpers.issue_registry import (
async_create_issue,
async_delete_issue,
)
-from homeassistant.helpers.start import async_at_start
+from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.setup import EventComponentLoaded
@@ -30,6 +30,8 @@ DOMAIN = "homeassistant_alerts"
UPDATE_INTERVAL = timedelta(hours=3)
_LOGGER = logging.getLogger(__name__)
+REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30)
+
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@@ -52,7 +54,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
try:
response = await async_get_clientsession(hass).get(
f"https://alerts.home-assistant.io/alerts/{alert.alert_id}.json",
- timeout=aiohttp.ClientTimeout(total=30),
+ timeout=REQUEST_TIMEOUT,
)
except TimeoutError:
_LOGGER.warning("Error fetching %s: timeout", alert.filename)
@@ -106,7 +108,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await coordinator.async_refresh()
hass.bus.async_listen(EVENT_COMPONENT_LOADED, _component_loaded)
- async_at_start(hass, initial_refresh)
+ async_at_started(hass, initial_refresh)
return True
@@ -146,7 +148,7 @@ class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]])
async def _async_update_data(self) -> dict[str, IntegrationAlert]:
response = await async_get_clientsession(self.hass).get(
"https://alerts.home-assistant.io/alerts.json",
- timeout=aiohttp.ClientTimeout(total=10),
+ timeout=REQUEST_TIMEOUT,
)
alerts = await response.json()
diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py
index a85a1161792..fc02f31f263 100644
--- a/homeassistant/components/homeassistant_sky_connect/__init__.py
+++ b/homeassistant/components/homeassistant_sky_connect/__init__.py
@@ -2,87 +2,62 @@
from __future__ import annotations
-from homeassistant.components import usb
-from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
- check_multi_pan_addon,
- get_zigbee_socket,
- multi_pan_addon_using_device,
-)
-from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
-from homeassistant.helpers import discovery_flow
+import logging
-from .const import DOMAIN
-from .util import get_hardware_variant, get_usb_service_info
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from .util import guess_firmware_type
-async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Finish Home Assistant SkyConnect config entry setup."""
- matcher = usb.USBCallbackMatcher(
- domain=DOMAIN,
- vid=entry.data["vid"].upper(),
- pid=entry.data["pid"].upper(),
- serial_number=entry.data["serial_number"].lower(),
- manufacturer=entry.data["manufacturer"].lower(),
- description=entry.data["description"].lower(),
- )
-
- if not usb.async_is_plugged_in(hass, matcher):
- # The USB dongle is not plugged in, remove the config entry
- hass.async_create_task(
- hass.config_entries.async_remove(entry.entry_id), eager_start=True
- )
- return
-
- usb_dev = entry.data["device"]
- # The call to get_serial_by_id can be removed in HA Core 2024.1
- dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev)
-
- if not await multi_pan_addon_using_device(hass, dev_path):
- usb_info = get_usb_service_info(entry)
- await hass.config_entries.flow.async_init(
- "zha",
- context={"source": "usb"},
- data=usb_info,
- )
- return
-
- hw_variant = get_hardware_variant(entry)
- hw_discovery_data = {
- "name": f"{hw_variant.short_name} Multiprotocol",
- "port": {
- "path": get_zigbee_socket(),
- },
- "radio_type": "ezsp",
- }
- discovery_flow.async_create_flow(
- hass,
- "zha",
- context={"source": SOURCE_HARDWARE},
- data=hw_discovery_data,
- )
+_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Home Assistant SkyConnect config entry."""
-
- try:
- await check_multi_pan_addon(hass)
- except HomeAssistantError as err:
- raise ConfigEntryNotReady from err
-
- @callback
- def async_usb_scan_done() -> None:
- """Handle usb discovery started."""
- hass.async_create_task(_async_usb_scan_done(hass, entry), eager_start=True)
-
- unsub_usb = usb.async_register_initial_scan_callback(hass, async_usb_scan_done)
- entry.async_on_unload(unsub_usb)
-
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return True
+
+
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+ """Migrate old entry."""
+
+ _LOGGER.debug(
+ "Migrating from version %s:%s", config_entry.version, config_entry.minor_version
+ )
+
+ if config_entry.version == 1:
+ if config_entry.minor_version == 1:
+ # Add-on startup with type service get started before Core, always (e.g. the
+ # Multi-Protocol add-on). Probing the firmware would interfere with the add-on,
+ # so we can't safely probe here. Instead, we must make an educated guess!
+ firmware_guess = await guess_firmware_type(
+ hass, config_entry.data["device"]
+ )
+
+ new_data = {**config_entry.data}
+ new_data["firmware"] = firmware_guess.firmware_type.value
+
+ # Copy `description` to `product`
+ new_data["product"] = new_data["description"]
+
+ hass.config_entries.async_update_entry(
+ config_entry,
+ data=new_data,
+ version=1,
+ minor_version=2,
+ )
+
+ _LOGGER.debug(
+ "Migration to version %s.%s successful",
+ config_entry.version,
+ config_entry.minor_version,
+ )
+
+ return True
+
+ # This means the user has downgraded from a future version
+ return False
diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py
index 3a3d32c2888..6ffb2783165 100644
--- a/homeassistant/components/homeassistant_sky_connect/config_flow.py
+++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py
@@ -2,29 +2,498 @@
from __future__ import annotations
+from abc import ABC, abstractmethod
+import asyncio
+import logging
from typing import Any
+from universal_silabs_flasher.const import ApplicationType
+
from homeassistant.components import usb
+from homeassistant.components.hassio import (
+ AddonError,
+ AddonInfo,
+ AddonManager,
+ AddonState,
+ is_hassio,
+)
from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon
-from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
+from homeassistant.components.zha.repairs.wrong_silabs_firmware import (
+ probe_silabs_firmware_type,
+)
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigEntryBaseFlow,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+ OptionsFlowWithConfigEntry,
+)
from homeassistant.core import callback
+from homeassistant.data_entry_flow import AbortFlow
-from .const import DOMAIN, HardwareVariant
-from .util import get_hardware_variant, get_usb_service_info
+from .const import DOCS_WEB_FLASHER_URL, DOMAIN, ZHA_DOMAIN, HardwareVariant
+from .util import (
+ get_hardware_variant,
+ get_otbr_addon_manager,
+ get_usb_service_info,
+ get_zha_device_path,
+ get_zigbee_flasher_addon_manager,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_PICK_FIRMWARE_THREAD = "pick_firmware_thread"
+STEP_PICK_FIRMWARE_ZIGBEE = "pick_firmware_zigbee"
-class HomeAssistantSkyConnectConfigFlow(ConfigFlow, domain=DOMAIN):
+class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
+ """Base flow to install firmware."""
+
+ _failed_addon_name: str
+ _failed_addon_reason: str
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ """Instantiate base flow."""
+ super().__init__(*args, **kwargs)
+
+ self._usb_info: usb.UsbServiceInfo | None = None
+ self._hw_variant: HardwareVariant | None = None
+ self._probed_firmware_type: ApplicationType | None = None
+
+ self.addon_install_task: asyncio.Task | None = None
+ self.addon_start_task: asyncio.Task | None = None
+ self.addon_uninstall_task: asyncio.Task | None = None
+
+ def _get_translation_placeholders(self) -> dict[str, str]:
+ """Shared translation placeholders."""
+ placeholders = {
+ "model": (
+ self._hw_variant.full_name
+ if self._hw_variant is not None
+ else "unknown"
+ ),
+ "firmware_type": (
+ self._probed_firmware_type.value
+ if self._probed_firmware_type is not None
+ else "unknown"
+ ),
+ "docs_web_flasher_url": DOCS_WEB_FLASHER_URL,
+ }
+
+ self.context["title_placeholders"] = placeholders
+
+ return placeholders
+
+ async def _async_set_addon_config(
+ self, config: dict, addon_manager: AddonManager
+ ) -> None:
+ """Set add-on config."""
+ try:
+ await addon_manager.async_set_addon_options(config)
+ except AddonError as err:
+ _LOGGER.error(err)
+ raise AbortFlow(
+ "addon_set_config_failed",
+ description_placeholders=self._get_translation_placeholders(),
+ ) from err
+
+ async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo:
+ """Return add-on info."""
+ try:
+ addon_info = await addon_manager.async_get_addon_info()
+ except AddonError as err:
+ _LOGGER.error(err)
+ raise AbortFlow(
+ "addon_info_failed",
+ description_placeholders={
+ **self._get_translation_placeholders(),
+ "addon_name": addon_manager.addon_name,
+ },
+ ) from err
+
+ return addon_info
+
+ async def async_step_pick_firmware(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Pick Thread or Zigbee firmware."""
+ assert self._usb_info is not None
+
+ self._probed_firmware_type = await probe_silabs_firmware_type(
+ self._usb_info.device,
+ probe_methods=(
+ # We probe in order of frequency: Zigbee, Thread, then multi-PAN
+ ApplicationType.GECKO_BOOTLOADER,
+ ApplicationType.EZSP,
+ ApplicationType.SPINEL,
+ ApplicationType.CPC,
+ ),
+ )
+
+ if self._probed_firmware_type not in (
+ ApplicationType.EZSP,
+ ApplicationType.SPINEL,
+ ApplicationType.CPC,
+ ):
+ return self.async_abort(
+ reason="unsupported_firmware",
+ description_placeholders=self._get_translation_placeholders(),
+ )
+
+ return self.async_show_menu(
+ step_id="pick_firmware",
+ menu_options=[
+ STEP_PICK_FIRMWARE_THREAD,
+ STEP_PICK_FIRMWARE_ZIGBEE,
+ ],
+ description_placeholders=self._get_translation_placeholders(),
+ )
+
+ async def async_step_pick_firmware_zigbee(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Pick Zigbee firmware."""
+ # Allow the stick to be used with ZHA without flashing
+ if self._probed_firmware_type == ApplicationType.EZSP:
+ return await self.async_step_confirm_zigbee()
+
+ if not is_hassio(self.hass):
+ return self.async_abort(
+ reason="not_hassio",
+ description_placeholders=self._get_translation_placeholders(),
+ )
+
+ # Only flash new firmware if we need to
+ fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
+ addon_info = await self._async_get_addon_info(fw_flasher_manager)
+
+ if addon_info.state == AddonState.NOT_INSTALLED:
+ return await self.async_step_install_zigbee_flasher_addon()
+
+ if addon_info.state == AddonState.NOT_RUNNING:
+ return await self.async_step_run_zigbee_flasher_addon()
+
+ # If the addon is already installed and running, fail
+ return self.async_abort(
+ reason="addon_already_running",
+ description_placeholders={
+ **self._get_translation_placeholders(),
+ "addon_name": fw_flasher_manager.addon_name,
+ },
+ )
+
+ async def async_step_install_zigbee_flasher_addon(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Show progress dialog for installing the Zigbee flasher addon."""
+ return await self._install_addon(
+ get_zigbee_flasher_addon_manager(self.hass),
+ "install_zigbee_flasher_addon",
+ "run_zigbee_flasher_addon",
+ )
+
+ async def _install_addon(
+ self,
+ addon_manager: silabs_multiprotocol_addon.WaitingAddonManager,
+ step_id: str,
+ next_step_id: str,
+ ) -> ConfigFlowResult:
+ """Show progress dialog for installing an addon."""
+ addon_info = await self._async_get_addon_info(addon_manager)
+
+ _LOGGER.debug("Flasher addon state: %s", addon_info)
+
+ if not self.addon_install_task:
+ self.addon_install_task = self.hass.async_create_task(
+ addon_manager.async_install_addon_waiting(),
+ "Addon install",
+ )
+
+ if not self.addon_install_task.done():
+ return self.async_show_progress(
+ step_id=step_id,
+ progress_action="install_addon",
+ description_placeholders={
+ **self._get_translation_placeholders(),
+ "addon_name": addon_manager.addon_name,
+ },
+ progress_task=self.addon_install_task,
+ )
+
+ try:
+ await self.addon_install_task
+ except AddonError as err:
+ _LOGGER.error(err)
+ self._failed_addon_name = addon_manager.addon_name
+ self._failed_addon_reason = "addon_install_failed"
+ return self.async_show_progress_done(next_step_id="addon_operation_failed")
+ finally:
+ self.addon_install_task = None
+
+ return self.async_show_progress_done(next_step_id=next_step_id)
+
+ async def async_step_addon_operation_failed(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Abort when add-on installation or start failed."""
+ return self.async_abort(
+ reason=self._failed_addon_reason,
+ description_placeholders={
+ **self._get_translation_placeholders(),
+ "addon_name": self._failed_addon_name,
+ },
+ )
+
+ async def async_step_run_zigbee_flasher_addon(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Configure the flasher addon to point to the SkyConnect and run it."""
+ fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
+ addon_info = await self._async_get_addon_info(fw_flasher_manager)
+
+ assert self._usb_info is not None
+ new_addon_config = {
+ **addon_info.options,
+ "device": self._usb_info.device,
+ "baudrate": 115200,
+ "bootloader_baudrate": 115200,
+ "flow_control": True,
+ }
+
+ _LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
+ await self._async_set_addon_config(new_addon_config, fw_flasher_manager)
+
+ if not self.addon_start_task:
+
+ async def start_and_wait_until_done() -> None:
+ await fw_flasher_manager.async_start_addon_waiting()
+ # Now that the addon is running, wait for it to finish
+ await fw_flasher_manager.async_wait_until_addon_state(
+ AddonState.NOT_RUNNING
+ )
+
+ self.addon_start_task = self.hass.async_create_task(
+ start_and_wait_until_done()
+ )
+
+ if not self.addon_start_task.done():
+ return self.async_show_progress(
+ step_id="run_zigbee_flasher_addon",
+ progress_action="run_zigbee_flasher_addon",
+ description_placeholders={
+ **self._get_translation_placeholders(),
+ "addon_name": fw_flasher_manager.addon_name,
+ },
+ progress_task=self.addon_start_task,
+ )
+
+ try:
+ await self.addon_start_task
+ except (AddonError, AbortFlow) as err:
+ _LOGGER.error(err)
+ self._failed_addon_name = fw_flasher_manager.addon_name
+ self._failed_addon_reason = "addon_start_failed"
+ return self.async_show_progress_done(next_step_id="addon_operation_failed")
+ finally:
+ self.addon_start_task = None
+
+ return self.async_show_progress_done(
+ next_step_id="uninstall_zigbee_flasher_addon"
+ )
+
+ async def async_step_uninstall_zigbee_flasher_addon(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Uninstall the flasher addon."""
+ fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
+
+ if not self.addon_uninstall_task:
+ _LOGGER.debug("Uninstalling flasher addon")
+ self.addon_uninstall_task = self.hass.async_create_task(
+ fw_flasher_manager.async_uninstall_addon_waiting()
+ )
+
+ if not self.addon_uninstall_task.done():
+ return self.async_show_progress(
+ step_id="uninstall_zigbee_flasher_addon",
+ progress_action="uninstall_zigbee_flasher_addon",
+ description_placeholders={
+ **self._get_translation_placeholders(),
+ "addon_name": fw_flasher_manager.addon_name,
+ },
+ progress_task=self.addon_uninstall_task,
+ )
+
+ try:
+ await self.addon_uninstall_task
+ except (AddonError, AbortFlow) as err:
+ _LOGGER.error(err)
+ # The uninstall failing isn't critical so we can just continue
+ finally:
+ self.addon_uninstall_task = None
+
+ return self.async_show_progress_done(next_step_id="confirm_zigbee")
+
+ async def async_step_confirm_zigbee(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm Zigbee setup."""
+ assert self._usb_info is not None
+ assert self._hw_variant is not None
+ self._probed_firmware_type = ApplicationType.EZSP
+
+ if user_input is not None:
+ await self.hass.config_entries.flow.async_init(
+ ZHA_DOMAIN,
+ context={"source": "hardware"},
+ data={
+ "name": self._hw_variant.full_name,
+ "port": {
+ "path": self._usb_info.device,
+ "baudrate": 115200,
+ "flow_control": "hardware",
+ },
+ "radio_type": "ezsp",
+ },
+ )
+
+ return self._async_flow_finished()
+
+ return self.async_show_form(
+ step_id="confirm_zigbee",
+ description_placeholders=self._get_translation_placeholders(),
+ )
+
+ async def async_step_pick_firmware_thread(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Pick Thread firmware."""
+ # We install the OTBR addon no matter what, since it is required to use Thread
+ if not is_hassio(self.hass):
+ return self.async_abort(
+ reason="not_hassio_thread",
+ description_placeholders=self._get_translation_placeholders(),
+ )
+
+ otbr_manager = get_otbr_addon_manager(self.hass)
+ addon_info = await self._async_get_addon_info(otbr_manager)
+
+ if addon_info.state == AddonState.NOT_INSTALLED:
+ return await self.async_step_install_otbr_addon()
+
+ if addon_info.state == AddonState.NOT_RUNNING:
+ return await self.async_step_start_otbr_addon()
+
+ # If the addon is already installed and running, fail
+ return self.async_abort(
+ reason="otbr_addon_already_running",
+ description_placeholders={
+ **self._get_translation_placeholders(),
+ "addon_name": otbr_manager.addon_name,
+ },
+ )
+
+ async def async_step_install_otbr_addon(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Show progress dialog for installing the OTBR addon."""
+ return await self._install_addon(
+ get_otbr_addon_manager(self.hass), "install_otbr_addon", "start_otbr_addon"
+ )
+
+ async def async_step_start_otbr_addon(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Configure OTBR to point to the SkyConnect and run the addon."""
+ otbr_manager = get_otbr_addon_manager(self.hass)
+ addon_info = await self._async_get_addon_info(otbr_manager)
+
+ assert self._usb_info is not None
+ new_addon_config = {
+ **addon_info.options,
+ "device": self._usb_info.device,
+ "baudrate": 460800,
+ "flow_control": True,
+ "autoflash_firmware": True,
+ }
+
+ _LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
+ await self._async_set_addon_config(new_addon_config, otbr_manager)
+
+ if not self.addon_start_task:
+ self.addon_start_task = self.hass.async_create_task(
+ otbr_manager.async_start_addon_waiting()
+ )
+
+ if not self.addon_start_task.done():
+ return self.async_show_progress(
+ step_id="start_otbr_addon",
+ progress_action="start_otbr_addon",
+ description_placeholders={
+ **self._get_translation_placeholders(),
+ "addon_name": otbr_manager.addon_name,
+ },
+ progress_task=self.addon_start_task,
+ )
+
+ try:
+ await self.addon_start_task
+ except (AddonError, AbortFlow) as err:
+ _LOGGER.error(err)
+ self._failed_addon_name = otbr_manager.addon_name
+ self._failed_addon_reason = "addon_start_failed"
+ return self.async_show_progress_done(next_step_id="addon_operation_failed")
+ finally:
+ self.addon_start_task = None
+
+ return self.async_show_progress_done(next_step_id="confirm_otbr")
+
+ async def async_step_confirm_otbr(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm OTBR setup."""
+ assert self._usb_info is not None
+ assert self._hw_variant is not None
+
+ self._probed_firmware_type = ApplicationType.SPINEL
+
+ if user_input is not None:
+ # OTBR discovery is done automatically via hassio
+ return self._async_flow_finished()
+
+ return self.async_show_form(
+ step_id="confirm_otbr",
+ description_placeholders=self._get_translation_placeholders(),
+ )
+
+ @abstractmethod
+ def _async_flow_finished(self) -> ConfigFlowResult:
+ """Finish the flow."""
+ # This should be implemented by a subclass
+ raise NotImplementedError
+
+
+class HomeAssistantSkyConnectConfigFlow(
+ BaseFirmwareInstallFlow, ConfigFlow, domain=DOMAIN
+):
"""Handle a config flow for Home Assistant SkyConnect."""
VERSION = 1
+ MINOR_VERSION = 2
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
- ) -> HomeAssistantSkyConnectOptionsFlow:
+ ) -> OptionsFlow:
"""Return the options flow."""
- return HomeAssistantSkyConnectOptionsFlow(config_entry)
+ firmware_type = ApplicationType(config_entry.data["firmware"])
+
+ if firmware_type is ApplicationType.CPC:
+ return HomeAssistantSkyConnectMultiPanOptionsFlowHandler(config_entry)
+
+ return HomeAssistantSkyConnectOptionsFlowHandler(config_entry)
async def async_step_usb(
self, discovery_info: usb.UsbServiceInfo
@@ -37,27 +506,62 @@ class HomeAssistantSkyConnectConfigFlow(ConfigFlow, domain=DOMAIN):
manufacturer = discovery_info.manufacturer
description = discovery_info.description
unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}"
+
if await self.async_set_unique_id(unique_id):
self._abort_if_unique_id_configured(updates={"device": device})
+ discovery_info.device = await self.hass.async_add_executor_job(
+ usb.get_serial_by_id, discovery_info.device
+ )
+
+ self._usb_info = discovery_info
+
assert description is not None
- hw_variant = HardwareVariant.from_usb_product_name(description)
+ self._hw_variant = HardwareVariant.from_usb_product_name(description)
+
+ return await self.async_step_confirm()
+
+ async def async_step_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm a discovery."""
+ self._set_confirm_only()
+
+ # Without confirmation, discovery can automatically progress into parts of the
+ # config flow logic that interacts with hardware.
+ if user_input is not None:
+ return await self.async_step_pick_firmware()
+
+ return self.async_show_form(
+ step_id="confirm",
+ description_placeholders=self._get_translation_placeholders(),
+ )
+
+ def _async_flow_finished(self) -> ConfigFlowResult:
+ """Create the config entry."""
+ assert self._usb_info is not None
+ assert self._hw_variant is not None
+ assert self._probed_firmware_type is not None
return self.async_create_entry(
- title=hw_variant.full_name,
+ title=self._hw_variant.full_name,
data={
- "device": device,
- "vid": vid,
- "pid": pid,
- "serial_number": serial_number,
- "manufacturer": manufacturer,
- "description": description,
+ "vid": self._usb_info.vid,
+ "pid": self._usb_info.pid,
+ "serial_number": self._usb_info.serial_number,
+ "manufacturer": self._usb_info.manufacturer,
+ "description": self._usb_info.description, # For backwards compatibility
+ "product": self._usb_info.description,
+ "device": self._usb_info.device,
+ "firmware": self._probed_firmware_type.value,
},
)
-class HomeAssistantSkyConnectOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler):
- """Handle an option flow for Home Assistant SkyConnect."""
+class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
+ silabs_multiprotocol_addon.OptionsFlowHandler
+):
+ """Multi-PAN options flow for Home Assistant SkyConnect."""
async def _async_serial_port_settings(
self,
@@ -92,3 +596,97 @@ class HomeAssistantSkyConnectOptionsFlow(silabs_multiprotocol_addon.OptionsFlowH
def _hardware_name(self) -> str:
"""Return the name of the hardware."""
return self._hw_variant.full_name
+
+
+class HomeAssistantSkyConnectOptionsFlowHandler(
+ BaseFirmwareInstallFlow, OptionsFlowWithConfigEntry
+):
+ """Zigbee and Thread options flow handlers."""
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ """Instantiate options flow."""
+ super().__init__(*args, **kwargs)
+
+ self._usb_info = get_usb_service_info(self.config_entry)
+ self._probed_firmware_type = ApplicationType(self.config_entry.data["firmware"])
+ self._hw_variant = HardwareVariant.from_usb_product_name(
+ self.config_entry.data["product"]
+ )
+
+ # Make `context` a regular dictionary
+ self.context = {}
+
+ # Regenerate the translation placeholders
+ self._get_translation_placeholders()
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage the options flow."""
+ # Don't probe the running firmware, we load it from the config entry
+ return self.async_show_menu(
+ step_id="pick_firmware",
+ menu_options=[
+ STEP_PICK_FIRMWARE_THREAD,
+ STEP_PICK_FIRMWARE_ZIGBEE,
+ ],
+ description_placeholders=self._get_translation_placeholders(),
+ )
+
+ async def async_step_pick_firmware_zigbee(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Pick Zigbee firmware."""
+ assert self._usb_info is not None
+
+ if is_hassio(self.hass):
+ otbr_manager = get_otbr_addon_manager(self.hass)
+ otbr_addon_info = await self._async_get_addon_info(otbr_manager)
+
+ if (
+ otbr_addon_info.state != AddonState.NOT_INSTALLED
+ and otbr_addon_info.options.get("device") == self._usb_info.device
+ ):
+ raise AbortFlow(
+ "otbr_still_using_stick",
+ description_placeholders=self._get_translation_placeholders(),
+ )
+
+ return await super().async_step_pick_firmware_zigbee(user_input)
+
+ async def async_step_pick_firmware_thread(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Pick Thread firmware."""
+ assert self._usb_info is not None
+
+ zha_entries = self.hass.config_entries.async_entries(
+ ZHA_DOMAIN,
+ include_ignore=False,
+ include_disabled=True,
+ )
+
+ if zha_entries and get_zha_device_path(zha_entries[0]) == self._usb_info.device:
+ raise AbortFlow(
+ "zha_still_using_stick",
+ description_placeholders=self._get_translation_placeholders(),
+ )
+
+ return await super().async_step_pick_firmware_thread(user_input)
+
+ def _async_flow_finished(self) -> ConfigFlowResult:
+ """Create the config entry."""
+ assert self._usb_info is not None
+ assert self._hw_variant is not None
+ assert self._probed_firmware_type is not None
+
+ self.hass.config_entries.async_update_entry(
+ entry=self.config_entry,
+ data={
+ **self.config_entry.data,
+ "firmware": self._probed_firmware_type.value,
+ },
+ options=self.config_entry.options,
+ )
+
+ return self.async_create_entry(title="", data={})
diff --git a/homeassistant/components/homeassistant_sky_connect/const.py b/homeassistant/components/homeassistant_sky_connect/const.py
index 1dd1471c470..1d6c16dc528 100644
--- a/homeassistant/components/homeassistant_sky_connect/const.py
+++ b/homeassistant/components/homeassistant_sky_connect/const.py
@@ -5,6 +5,17 @@ import enum
from typing import Self
DOMAIN = "homeassistant_sky_connect"
+ZHA_DOMAIN = "zha"
+
+DOCS_WEB_FLASHER_URL = "https://skyconnect.home-assistant.io/firmware-update/"
+
+OTBR_ADDON_NAME = "OpenThread Border Router"
+OTBR_ADDON_MANAGER_DATA = "openthread_border_router"
+OTBR_ADDON_SLUG = "core_openthread_border_router"
+
+ZIGBEE_FLASHER_ADDON_NAME = "Silicon Labs Flasher"
+ZIGBEE_FLASHER_ADDON_MANAGER_DATA = "silabs_flasher"
+ZIGBEE_FLASHER_ADDON_SLUG = "core_silabs_flasher"
@dataclasses.dataclass(frozen=True)
diff --git a/homeassistant/components/homeassistant_sky_connect/hardware.py b/homeassistant/components/homeassistant_sky_connect/hardware.py
index a9abeb27737..2872077111a 100644
--- a/homeassistant/components/homeassistant_sky_connect/hardware.py
+++ b/homeassistant/components/homeassistant_sky_connect/hardware.py
@@ -25,7 +25,7 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
pid=entry.data["pid"],
serial_number=entry.data["serial_number"],
manufacturer=entry.data["manufacturer"],
- description=entry.data["description"],
+ description=entry.data["product"],
),
name=get_hardware_variant(entry).full_name,
url=DOCUMENTATION_URL,
diff --git a/homeassistant/components/homeassistant_sky_connect/manifest.json b/homeassistant/components/homeassistant_sky_connect/manifest.json
index f56fd24de61..c90ea2c075f 100644
--- a/homeassistant/components/homeassistant_sky_connect/manifest.json
+++ b/homeassistant/components/homeassistant_sky_connect/manifest.json
@@ -5,7 +5,7 @@
"config_flow": true,
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
- "integration_type": "hardware",
+ "integration_type": "device",
"usb": [
{
"vid": "10C4",
diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json
index 825649ef0d3..792406dcb02 100644
--- a/homeassistant/components/homeassistant_sky_connect/strings.json
+++ b/homeassistant/components/homeassistant_sky_connect/strings.json
@@ -57,6 +57,50 @@
"start_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]"
+ },
+ "confirm": {
+ "title": "[%key:component::homeassistant_sky_connect::config::step::confirm::title%]",
+ "description": "[%key:component::homeassistant_sky_connect::config::step::confirm::description%]"
+ },
+ "pick_firmware": {
+ "title": "[%key:component::homeassistant_sky_connect::config::step::pick_firmware::title%]",
+ "description": "[%key:component::homeassistant_sky_connect::config::step::pick_firmware::description%]",
+ "menu_options": {
+ "pick_firmware_thread": "[%key:component::homeassistant_sky_connect::config::step::pick_firmware::menu_options::pick_firmware_thread%]",
+ "pick_firmware_zigbee": "[%key:component::homeassistant_sky_connect::config::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
+ }
+ },
+ "install_zigbee_flasher_addon": {
+ "title": "[%key:component::homeassistant_sky_connect::config::step::install_zigbee_flasher_addon::title%]",
+ "description": "[%key:component::homeassistant_sky_connect::config::step::install_zigbee_flasher_addon::description%]"
+ },
+ "run_zigbee_flasher_addon": {
+ "title": "[%key:component::homeassistant_sky_connect::config::step::run_zigbee_flasher_addon::title%]",
+ "description": "[%key:component::homeassistant_sky_connect::config::step::run_zigbee_flasher_addon::description%]"
+ },
+ "zigbee_flasher_failed": {
+ "title": "[%key:component::homeassistant_sky_connect::config::step::zigbee_flasher_failed::title%]",
+ "description": "[%key:component::homeassistant_sky_connect::config::step::zigbee_flasher_failed::description%]"
+ },
+ "confirm_zigbee": {
+ "title": "[%key:component::homeassistant_sky_connect::config::step::confirm_zigbee::title%]",
+ "description": "[%key:component::homeassistant_sky_connect::config::step::confirm_zigbee::description%]"
+ },
+ "install_otbr_addon": {
+ "title": "[%key:component::homeassistant_sky_connect::config::step::install_otbr_addon::title%]",
+ "description": "[%key:component::homeassistant_sky_connect::config::step::install_otbr_addon::description%]"
+ },
+ "start_otbr_addon": {
+ "title": "[%key:component::homeassistant_sky_connect::config::step::start_otbr_addon::title%]",
+ "description": "[%key:component::homeassistant_sky_connect::config::step::start_otbr_addon::description%]"
+ },
+ "otbr_failed": {
+ "title": "[%key:component::homeassistant_sky_connect::config::step::otbr_failed::title%]",
+ "description": "[%key:component::homeassistant_sky_connect::config::step::otbr_failed::description%]"
+ },
+ "confirm_otbr": {
+ "title": "[%key:component::homeassistant_sky_connect::config::step::confirm_otbr::title%]",
+ "description": "[%key:component::homeassistant_sky_connect::config::step::confirm_otbr::description%]"
}
},
"error": {
@@ -68,12 +112,92 @@
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
+ "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]",
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
- "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
+ "not_hassio_thread": "[%key:component::homeassistant_sky_connect::config::abort::not_hassio_thread%]",
+ "otbr_addon_already_running": "[%key:component::homeassistant_sky_connect::config::abort::otbr_addon_already_running%]",
+ "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.",
+ "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again."
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
- "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]"
+ "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
+ "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
+ "install_zigbee_flasher_addon": "[%key:component::homeassistant_sky_connect::config::progress::install_zigbee_flasher_addon%]",
+ "run_zigbee_flasher_addon": "[%key:component::homeassistant_sky_connect::config::progress::run_zigbee_flasher_addon%]",
+ "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_sky_connect::config::progress::uninstall_zigbee_flasher_addon%]"
+ }
+ },
+ "config": {
+ "flow_title": "{model}",
+ "step": {
+ "confirm": {
+ "title": "Set up the {model}",
+ "description": "The {model} can be used as either a Thread border router or a Zigbee coordinator. In the next step, you will choose which firmware will be configured."
+ },
+ "pick_firmware": {
+ "title": "Pick your firmware",
+ "description": "The {model} can be used as a Thread border router or a Zigbee coordinator.",
+ "menu_options": {
+ "pick_firmware_thread": "Use as a Thread border router",
+ "pick_firmware_zigbee": "Use as a Zigbee coordinator"
+ }
+ },
+ "install_zigbee_flasher_addon": {
+ "title": "Installing flasher",
+ "description": "Installing the Silicon Labs Flasher add-on."
+ },
+ "run_zigbee_flasher_addon": {
+ "title": "Installing Zigbee firmware",
+ "description": "Installing Zigbee firmware. This will take about a minute."
+ },
+ "uninstall_zigbee_flasher_addon": {
+ "title": "Removing flasher",
+ "description": "Removing the Silicon Labs Flasher add-on."
+ },
+ "zigbee_flasher_failed": {
+ "title": "Zigbee installation failed",
+ "description": "The Zigbee firmware installation process was unsuccessful. Ensure no other software is trying to communicate with the {model} and try again."
+ },
+ "confirm_zigbee": {
+ "title": "Zigbee setup complete",
+ "description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration once you exit."
+ },
+ "install_otbr_addon": {
+ "title": "Installing OpenThread Border Router add-on",
+ "description": "The OpenThread Border Router (OTBR) add-on is being installed."
+ },
+ "start_otbr_addon": {
+ "title": "Starting OpenThread Border Router add-on",
+ "description": "The OpenThread Border Router (OTBR) add-on is now starting."
+ },
+ "otbr_failed": {
+ "title": "Failed to setup OpenThread Border Router",
+ "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists."
+ },
+ "confirm_otbr": {
+ "title": "OpenThread Border Router setup complete",
+ "description": "Your {model} is now an OpenThread Border Router and will show up in the Thread integration once you exit."
+ }
+ },
+ "abort": {
+ "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
+ "addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
+ "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
+ "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
+ "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
+ "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]",
+ "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
+ "not_hassio_thread": "The OpenThread Border Router addon can only be installed with Home Assistant OS. If you would like to use the {model} as an Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.",
+ "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again."
+ },
+ "progress": {
+ "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
+ "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
+ "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
+ "install_zigbee_flasher_addon": "The Silicon Labs Flasher addon is installed, this may take a few minutes.",
+ "run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.",
+ "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher addon is being removed."
}
}
}
diff --git a/homeassistant/components/homeassistant_sky_connect/util.py b/homeassistant/components/homeassistant_sky_connect/util.py
index e1de1d3b442..f242416fa9a 100644
--- a/homeassistant/components/homeassistant_sky_connect/util.py
+++ b/homeassistant/components/homeassistant_sky_connect/util.py
@@ -2,10 +2,35 @@
from __future__ import annotations
-from homeassistant.components import usb
-from homeassistant.config_entries import ConfigEntry
+from collections import defaultdict
+from dataclasses import dataclass
+import logging
+from typing import cast
-from .const import HardwareVariant
+from universal_silabs_flasher.const import ApplicationType
+
+from homeassistant.components import usb
+from homeassistant.components.hassio import AddonError, AddonState, is_hassio
+from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
+ WaitingAddonManager,
+ get_multiprotocol_addon_manager,
+)
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.singleton import singleton
+
+from .const import (
+ OTBR_ADDON_MANAGER_DATA,
+ OTBR_ADDON_NAME,
+ OTBR_ADDON_SLUG,
+ ZHA_DOMAIN,
+ ZIGBEE_FLASHER_ADDON_MANAGER_DATA,
+ ZIGBEE_FLASHER_ADDON_NAME,
+ ZIGBEE_FLASHER_ADDON_SLUG,
+ HardwareVariant,
+)
+
+_LOGGER = logging.getLogger(__name__)
def get_usb_service_info(config_entry: ConfigEntry) -> usb.UsbServiceInfo:
@@ -16,10 +41,115 @@ def get_usb_service_info(config_entry: ConfigEntry) -> usb.UsbServiceInfo:
pid=config_entry.data["pid"],
serial_number=config_entry.data["serial_number"],
manufacturer=config_entry.data["manufacturer"],
- description=config_entry.data["description"],
+ description=config_entry.data["product"],
)
def get_hardware_variant(config_entry: ConfigEntry) -> HardwareVariant:
"""Get the hardware variant from the config entry."""
- return HardwareVariant.from_usb_product_name(config_entry.data["description"])
+ return HardwareVariant.from_usb_product_name(config_entry.data["product"])
+
+
+def get_zha_device_path(config_entry: ConfigEntry) -> str:
+ """Get the device path from a ZHA config entry."""
+ return cast(str, config_entry.data["device"]["path"])
+
+
+@singleton(OTBR_ADDON_MANAGER_DATA)
+@callback
+def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
+ """Get the OTBR add-on manager."""
+ return WaitingAddonManager(
+ hass,
+ _LOGGER,
+ OTBR_ADDON_NAME,
+ OTBR_ADDON_SLUG,
+ )
+
+
+@singleton(ZIGBEE_FLASHER_ADDON_MANAGER_DATA)
+@callback
+def get_zigbee_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
+ """Get the flasher add-on manager."""
+ return WaitingAddonManager(
+ hass,
+ _LOGGER,
+ ZIGBEE_FLASHER_ADDON_NAME,
+ ZIGBEE_FLASHER_ADDON_SLUG,
+ )
+
+
+@dataclass(slots=True, kw_only=True)
+class FirmwareGuess:
+ """Firmware guess."""
+
+ is_running: bool
+ firmware_type: ApplicationType
+ source: str
+
+
+async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> FirmwareGuess:
+ """Guess the firmware type based on installed addons and other integrations."""
+ device_guesses: defaultdict[str | None, list[FirmwareGuess]] = defaultdict(list)
+
+ for zha_config_entry in hass.config_entries.async_entries(ZHA_DOMAIN):
+ zha_path = get_zha_device_path(zha_config_entry)
+ device_guesses[zha_path].append(
+ FirmwareGuess(
+ is_running=(zha_config_entry.state == ConfigEntryState.LOADED),
+ firmware_type=ApplicationType.EZSP,
+ source="zha",
+ )
+ )
+
+ if is_hassio(hass):
+ otbr_addon_manager = get_otbr_addon_manager(hass)
+
+ try:
+ otbr_addon_info = await otbr_addon_manager.async_get_addon_info()
+ except AddonError:
+ pass
+ else:
+ if otbr_addon_info.state != AddonState.NOT_INSTALLED:
+ otbr_path = otbr_addon_info.options.get("device")
+ device_guesses[otbr_path].append(
+ FirmwareGuess(
+ is_running=(otbr_addon_info.state == AddonState.RUNNING),
+ firmware_type=ApplicationType.SPINEL,
+ source="otbr",
+ )
+ )
+
+ multipan_addon_manager = await get_multiprotocol_addon_manager(hass)
+
+ try:
+ multipan_addon_info = await multipan_addon_manager.async_get_addon_info()
+ except AddonError:
+ pass
+ else:
+ if multipan_addon_info.state != AddonState.NOT_INSTALLED:
+ multipan_path = multipan_addon_info.options.get("device")
+ device_guesses[multipan_path].append(
+ FirmwareGuess(
+ is_running=(multipan_addon_info.state == AddonState.RUNNING),
+ firmware_type=ApplicationType.CPC,
+ source="multiprotocol",
+ )
+ )
+
+ # Fall back to EZSP if we can't guess the firmware type
+ if device_path not in device_guesses:
+ return FirmwareGuess(
+ is_running=False, firmware_type=ApplicationType.EZSP, source="unknown"
+ )
+
+ # Prioritizes guesses that were pulled from a running addon or integration but keep
+ # the sort order we defined above
+ guesses = sorted(
+ device_guesses[device_path],
+ key=lambda guess: guess.is_running,
+ )
+
+ assert guesses
+
+ return guesses[-1]
diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py
index f2e1a26b3de..40e86efe6a9 100644
--- a/homeassistant/components/homekit/accessories.py
+++ b/homeassistant/components/homekit/accessories.py
@@ -46,6 +46,7 @@ from homeassistant.core import (
Context,
Event,
EventStateChangedData,
+ HassJobType,
HomeAssistant,
State,
callback as ha_callback,
@@ -436,7 +437,10 @@ class HomeAccessory(Accessory): # type: ignore[misc]
self._update_available_from_state(state)
self._subscriptions.append(
async_track_state_change_event(
- self.hass, [self.entity_id], self.async_update_event_state_callback
+ self.hass,
+ [self.entity_id],
+ self.async_update_event_state_callback,
+ job_type=HassJobType.Callback,
)
)
@@ -456,6 +460,7 @@ class HomeAccessory(Accessory): # type: ignore[misc]
self.hass,
[self.linked_battery_sensor],
self.async_update_linked_battery_callback,
+ job_type=HassJobType.Callback,
)
)
elif state is not None:
@@ -468,6 +473,7 @@ class HomeAccessory(Accessory): # type: ignore[misc]
self.hass,
[self.linked_battery_charging_sensor],
self.async_update_linked_battery_charging_callback,
+ job_type=HassJobType.Callback,
)
)
elif battery_charging_state is None and state is not None:
diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py
index d14fef8eabf..4f05bfbd687 100644
--- a/homeassistant/components/homekit/type_cameras.py
+++ b/homeassistant/components/homekit/type_cameras.py
@@ -20,6 +20,7 @@ from homeassistant.const import STATE_ON
from homeassistant.core import (
Event,
EventStateChangedData,
+ HassJobType,
HomeAssistant,
State,
callback,
@@ -272,6 +273,7 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
self.hass,
[self.linked_motion_sensor],
self._async_update_motion_state_event,
+ job_type=HassJobType.Callback,
)
)
@@ -282,6 +284,7 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
self.hass,
[self.linked_doorbell_sensor],
self._async_update_doorbell_state_event,
+ job_type=HassJobType.Callback,
)
)
diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py
index d14713b5f05..29dda418665 100644
--- a/homeassistant/components/homekit/type_covers.py
+++ b/homeassistant/components/homekit/type_covers.py
@@ -34,7 +34,13 @@ from homeassistant.const import (
STATE_OPEN,
STATE_OPENING,
)
-from homeassistant.core import Event, EventStateChangedData, State, callback
+from homeassistant.core import (
+ Event,
+ EventStateChangedData,
+ HassJobType,
+ State,
+ callback,
+)
from homeassistant.helpers.event import async_track_state_change_event
from .accessories import TYPES, HomeAccessory
@@ -136,6 +142,7 @@ class GarageDoorOpener(HomeAccessory):
self.hass,
[self.linked_obstruction_sensor],
self._async_update_obstruction_event,
+ job_type=HassJobType.Callback,
)
)
diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py
index 1fca441e800..5bdf5950f18 100644
--- a/homeassistant/components/homekit/type_humidifiers.py
+++ b/homeassistant/components/homekit/type_humidifiers.py
@@ -25,7 +25,13 @@ from homeassistant.const import (
SERVICE_TURN_ON,
STATE_ON,
)
-from homeassistant.core import Event, EventStateChangedData, State, callback
+from homeassistant.core import (
+ Event,
+ EventStateChangedData,
+ HassJobType,
+ State,
+ callback,
+)
from homeassistant.helpers.event import async_track_state_change_event
from .accessories import TYPES, HomeAccessory
@@ -184,6 +190,7 @@ class HumidifierDehumidifier(HomeAccessory):
self.hass,
[self.linked_humidity_sensor],
self.async_update_current_humidity_event,
+ job_type=HassJobType.Callback,
)
)
diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py
index b0eb2a9edfa..dd89efed1c9 100644
--- a/homeassistant/components/homematicip_cloud/climate.py
+++ b/homeassistant/components/homematicip_cloud/climate.py
@@ -13,6 +13,7 @@ from homematicip.aio.group import AsyncHeatingGroup
from homematicip.base.enums import AbsenceType
from homematicip.device import Switch
from homematicip.functionalHomes import IndoorClimateHome
+from homematicip.group import HeatingCoolingProfile
from homeassistant.components.climate import (
PRESET_AWAY,
@@ -35,6 +36,14 @@ from .hap import HomematicipHAP
HEATING_PROFILES = {"PROFILE_1": 0, "PROFILE_2": 1, "PROFILE_3": 2}
COOLING_PROFILES = {"PROFILE_4": 3, "PROFILE_5": 4, "PROFILE_6": 5}
+NICE_PROFILE_NAMES = {
+ "PROFILE_1": "Default",
+ "PROFILE_2": "Alternative 1",
+ "PROFILE_3": "Alternative 2",
+ "PROFILE_4": "Cooling 1",
+ "PROFILE_5": "Cooling 2",
+ "PROFILE_6": "Cooling 3",
+}
ATTR_PRESET_END_TIME = "preset_end_time"
PERMANENT_END_TIME = "permanent"
@@ -164,8 +173,9 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
return PRESET_ECO
return (
- self._device.activeProfile.name
- if self._device.activeProfile.name in self._device_profile_names
+ self._get_qualified_profile_name(self._device.activeProfile)
+ if self._get_qualified_profile_name(self._device.activeProfile)
+ in self._device_profile_names
else None
)
@@ -218,9 +228,6 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
- if preset_mode not in self.preset_modes:
- return
-
if self._device.boostMode and preset_mode != PRESET_BOOST:
await self._device.set_boost(False)
if preset_mode == PRESET_BOOST:
@@ -256,20 +263,30 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
return self._home.get_functionalHome(IndoorClimateHome)
@property
- def _device_profiles(self) -> list[Any]:
+ def _device_profiles(self) -> list[HeatingCoolingProfile]:
"""Return the relevant profiles."""
return [
profile
for profile in self._device.profiles
- if profile.visible
- and profile.name != ""
- and profile.index in self._relevant_profile_group
+ if profile.visible and profile.index in self._relevant_profile_group
]
@property
def _device_profile_names(self) -> list[str]:
"""Return a collection of profile names."""
- return [profile.name for profile in self._device_profiles]
+ return [
+ self._get_qualified_profile_name(profile)
+ for profile in self._device_profiles
+ ]
+
+ def _get_qualified_profile_name(self, profile: HeatingCoolingProfile) -> str:
+ """Get a name for the given profile. If exists, this is the name of the profile."""
+ if profile.name != "":
+ return profile.name
+ if profile.index in NICE_PROFILE_NAMES:
+ return NICE_PROFILE_NAMES[profile.index]
+
+ return profile.index
def _get_profile_idx_by_name(self, profile_name: str) -> int:
"""Return a profile index by name."""
@@ -277,7 +294,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
index_name = [
profile.index
for profile in self._device_profiles
- if profile.name == profile_name
+ if self._get_qualified_profile_name(profile) == profile_name
]
return relevant_index[index_name[0]]
diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json
index 580a0f637c1..9da4e1bee05 100644
--- a/homeassistant/components/homematicip_cloud/manifest.json
+++ b/homeassistant/components/homematicip_cloud/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "homematicip_cloud",
"name": "HomematicIP Cloud",
- "codeowners": [],
+ "codeowners": ["@hahn-th"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py
index b9515c306d6..f447860c53f 100644
--- a/homeassistant/components/homeworks/config_flow.py
+++ b/homeassistant/components/homeworks/config_flow.py
@@ -690,7 +690,10 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PORT: user_input[CONF_PORT],
}
return self.async_update_reload_and_abort(
- entry, options=new_options, reason="reconfigure_successful"
+ entry,
+ options=new_options,
+ reason="reconfigure_successful",
+ reload_even_if_entry_is_unchanged=False,
)
return self.async_show_form(
diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py
index 782340dffa6..6049f8e2434 100644
--- a/homeassistant/components/html5/notify.py
+++ b/homeassistant/components/html5/notify.py
@@ -165,7 +165,7 @@ HTML5_SHOWNOTIFICATION_PARAMETERS = (
)
-def get_service(
+async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
@@ -173,7 +173,7 @@ def get_service(
"""Get the HTML5 push notification service."""
json_path = hass.config.path(REGISTRATIONS_FILE)
- registrations = _load_config(json_path)
+ registrations = await hass.async_add_executor_job(_load_config, json_path)
vapid_pub_key = config[ATTR_VAPID_PUB_KEY]
vapid_prv_key = config[ATTR_VAPID_PRV_KEY]
diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py
index f9532b90ce6..83601599d88 100644
--- a/homeassistant/components/http/__init__.py
+++ b/homeassistant/components/http/__init__.py
@@ -69,6 +69,7 @@ from homeassistant.util.json import json_loads
from .auth import async_setup_auth, async_sign_path
from .ban import setup_bans
from .const import ( # noqa: F401
+ DOMAIN,
KEY_HASS_REFRESH_TOKEN_ID,
KEY_HASS_USER,
StrictConnectionMode,
@@ -82,8 +83,6 @@ from .security_filter import setup_security_filter
from .static import CACHE_HEADERS, CachingStaticResource
from .web_runner import HomeAssistantTCPSite
-DOMAIN: Final = "http"
-
CONF_SERVER_HOST: Final = "server_host"
CONF_SERVER_PORT: Final = "server_port"
CONF_BASE_URL: Final = "base_url"
@@ -149,7 +148,7 @@ HTTP_SCHEMA: Final = vol.All(
vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean,
vol.Optional(
CONF_STRICT_CONNECTION, default=StrictConnectionMode.DISABLED
- ): vol.In([e.value for e in StrictConnectionMode]),
+ ): vol.Coerce(StrictConnectionMode),
}
),
)
@@ -628,7 +627,9 @@ def _setup_services(hass: HomeAssistant, conf: ConfData) -> None:
)
try:
- url = get_url(hass, prefer_external=True, allow_internal=False)
+ url = get_url(
+ hass, prefer_external=True, allow_internal=False, allow_cloud=False
+ )
except NoURLAvailableError as ex:
raise ServiceValidationError(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py
index 1eb74289089..58dae21d2a6 100644
--- a/homeassistant/components/http/auth.py
+++ b/homeassistant/components/http/auth.py
@@ -25,6 +25,7 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY
from homeassistant.auth.models import User
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import singleton
from homeassistant.helpers.http import current_request
from homeassistant.helpers.json import json_bytes
from homeassistant.helpers.network import is_cloud_connection
@@ -32,6 +33,7 @@ from homeassistant.helpers.storage import Store
from homeassistant.util.network import is_local
from .const import (
+ DOMAIN,
KEY_AUTHENTICATED,
KEY_HASS_REFRESH_TOKEN_ID,
KEY_HASS_USER,
@@ -50,8 +52,9 @@ STORAGE_VERSION = 1
STORAGE_KEY = "http.auth"
CONTENT_USER_NAME = "Home Assistant Content"
STRICT_CONNECTION_EXCLUDED_PATH = "/api/webhook/"
-STRICT_CONNECTION_STATIC_PAGE = os.path.join(
- os.path.dirname(__file__), "strict_connection_static_page.html"
+STRICT_CONNECTION_GUARD_PAGE_NAME = "strict_connection_guard_page.html"
+STRICT_CONNECTION_GUARD_PAGE = os.path.join(
+ os.path.dirname(__file__), STRICT_CONNECTION_GUARD_PAGE_NAME
)
@@ -156,16 +159,10 @@ async def async_setup_auth(
await store.async_save(data)
hass.data[STORAGE_KEY] = refresh_token.id
- strict_connection_static_file_content = None
- if strict_connection_mode_non_cloud is StrictConnectionMode.STATIC_PAGE:
- def read_static_page() -> str:
- with open(STRICT_CONNECTION_STATIC_PAGE, encoding="utf-8") as file:
- return file.read()
-
- strict_connection_static_file_content = await hass.async_add_executor_job(
- read_static_page
- )
+ if strict_connection_mode_non_cloud is StrictConnectionMode.GUARD_PAGE:
+ # Load the guard page content on setup
+ await _read_strict_connection_guard_page(hass)
@callback
def async_validate_auth_header(request: Request) -> bool:
@@ -255,21 +252,36 @@ async def async_setup_auth(
authenticated = True
auth_type = "signed request"
- if (
- not authenticated
- and strict_connection_mode_non_cloud is not StrictConnectionMode.DISABLED
- and not request.path.startswith(STRICT_CONNECTION_EXCLUDED_PATH)
- and not await hass.auth.session.async_validate_request_for_strict_connection_session(
- request
- )
- and (
- resp := _async_perform_action_on_non_local(
- request, strict_connection_static_file_content
- )
- )
- is not None
+ if not authenticated and not request.path.startswith(
+ STRICT_CONNECTION_EXCLUDED_PATH
):
- return resp
+ strict_connection_mode = strict_connection_mode_non_cloud
+ strict_connection_func = (
+ _async_perform_strict_connection_action_on_non_local
+ )
+ if is_cloud_connection(hass):
+ from homeassistant.components.cloud.util import ( # pylint: disable=import-outside-toplevel
+ get_strict_connection_mode,
+ )
+
+ strict_connection_mode = get_strict_connection_mode(hass)
+ strict_connection_func = _async_perform_strict_connection_action
+
+ if (
+ strict_connection_mode is not StrictConnectionMode.DISABLED
+ and not await hass.auth.session.async_validate_request_for_strict_connection_session(
+ request
+ )
+ and (
+ resp := await strict_connection_func(
+ hass,
+ request,
+ strict_connection_mode is StrictConnectionMode.GUARD_PAGE,
+ )
+ )
+ is not None
+ ):
+ return resp
if authenticated and _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
@@ -286,17 +298,17 @@ async def async_setup_auth(
app.middlewares.append(auth_middleware)
-@callback
-def _async_perform_action_on_non_local(
+async def _async_perform_strict_connection_action_on_non_local(
+ hass: HomeAssistant,
request: Request,
- strict_connection_static_file_content: str | None,
+ guard_page: bool,
) -> StreamResponse | None:
"""Perform strict connection mode action if the request is not local.
The function does the following:
- Try to get the IP address of the request. If it fails, assume it's not local
- If the request is local, return None (allow the request to continue)
- - If strict_connection_static_file_content is set, return a response with the content
+ - If guard_page is True, return a response with the content
- Otherwise close the connection and raise an exception
"""
try:
@@ -308,10 +320,25 @@ def _async_perform_action_on_non_local(
if ip_address_ and is_local(ip_address_):
return None
- _LOGGER.debug("Perform strict connection action for %s", ip_address_)
- if strict_connection_static_file_content:
+ return await _async_perform_strict_connection_action(hass, request, guard_page)
+
+
+async def _async_perform_strict_connection_action(
+ hass: HomeAssistant,
+ request: Request,
+ guard_page: bool,
+) -> StreamResponse | None:
+ """Perform strict connection mode action.
+
+ The function does the following:
+ - If guard_page is True, return a response with the content
+ - Otherwise close the connection and raise an exception
+ """
+
+ _LOGGER.debug("Perform strict connection action for %s", request.remote)
+ if guard_page:
return Response(
- text=strict_connection_static_file_content,
+ text=await _read_strict_connection_guard_page(hass),
content_type="text/html",
status=HTTPStatus.IM_A_TEAPOT,
)
@@ -322,3 +349,14 @@ def _async_perform_action_on_non_local(
# We need to raise an exception to stop processing the request
raise HTTPBadRequest
+
+
+@singleton.singleton(f"{DOMAIN}_{STRICT_CONNECTION_GUARD_PAGE_NAME}")
+async def _read_strict_connection_guard_page(hass: HomeAssistant) -> str:
+ """Read the strict connection guard page from disk via executor."""
+
+ def read_guard_page() -> str:
+ with open(STRICT_CONNECTION_GUARD_PAGE, encoding="utf-8") as file:
+ return file.read()
+
+ return await hass.async_add_executor_job(read_guard_page)
diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py
index d02416c531b..4a15e310b11 100644
--- a/homeassistant/components/http/const.py
+++ b/homeassistant/components/http/const.py
@@ -5,6 +5,8 @@ from typing import Final
from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401
+DOMAIN: Final = "http"
+
KEY_HASS_USER: Final = "hass_user"
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
@@ -13,5 +15,5 @@ class StrictConnectionMode(StrEnum):
"""Enum for strict connection mode."""
DISABLED = "disabled"
- STATIC_PAGE = "static_page"
+ GUARD_PAGE = "guard_page"
DROP_CONNECTION = "drop_connection"
diff --git a/homeassistant/components/http/strict_connection_static_page.html b/homeassistant/components/http/strict_connection_guard_page.html
similarity index 99%
rename from homeassistant/components/http/strict_connection_static_page.html
rename to homeassistant/components/http/strict_connection_guard_page.html
index 86ea8e00e90..8567e500c9d 100644
--- a/homeassistant/components/http/strict_connection_static_page.html
+++ b/homeassistant/components/http/strict_connection_guard_page.html
@@ -123,7 +123,7 @@
You need access
- This device is not known on
+ This device is not known to
Home Assistant.
diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py
index 03ab02429bb..fe6f6978014 100644
--- a/homeassistant/components/husqvarna_automower/__init__.py
+++ b/homeassistant/components/husqvarna_automower/__init__.py
@@ -21,6 +21,7 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.DEVICE_TRACKER,
Platform.LAWN_MOWER,
+ Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
diff --git a/homeassistant/components/husqvarna_automower/api.py b/homeassistant/components/husqvarna_automower/api.py
index e5dc00ad7cb..f1d3e1ef4fa 100644
--- a/homeassistant/components/husqvarna_automower/api.py
+++ b/homeassistant/components/husqvarna_automower/api.py
@@ -1,6 +1,7 @@
"""API for Husqvarna Automower bound to Home Assistant OAuth."""
import logging
+from typing import cast
from aioautomower.auth import AbstractAuth
from aioautomower.const import API_BASE_URL
@@ -26,4 +27,4 @@ class AsyncConfigEntryAuth(AbstractAuth):
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
- return self._oauth_session.token["access_token"]
+ return cast(str, self._oauth_session.token["access_token"])
diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py
index a32fd8758bd..780d1da76fb 100644
--- a/homeassistant/components/husqvarna_automower/device_tracker.py
+++ b/homeassistant/components/husqvarna_automower/device_tracker.py
@@ -1,5 +1,7 @@
"""Creates the device tracker entity for the mower."""
+from typing import TYPE_CHECKING
+
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -44,9 +46,13 @@ class AutomowerDeviceTrackerEntity(AutomowerBaseEntity, TrackerEntity):
@property
def latitude(self) -> float:
"""Return latitude value of the device."""
+ if TYPE_CHECKING:
+ assert self.mower_attributes.positions is not None
return self.mower_attributes.positions[0].latitude
@property
def longitude(self) -> float:
"""Return longitude value of the device."""
+ if TYPE_CHECKING:
+ assert self.mower_attributes.positions is not None
return self.mower_attributes.positions[0].longitude
diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json
index ec11ef92d08..2ecbf9c198a 100644
--- a/homeassistant/components/husqvarna_automower/icons.json
+++ b/homeassistant/components/husqvarna_automower/icons.json
@@ -8,6 +8,11 @@
"default": "mdi:debug-step-into"
}
},
+ "number": {
+ "cutting_height": {
+ "default": "mdi:grass"
+ }
+ },
"select": {
"headlight_mode": {
"default": "mdi:car-light-high"
diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json
index e4536ee594d..647320a8bf3 100644
--- a/homeassistant/components/husqvarna_automower/manifest.json
+++ b/homeassistant/components/husqvarna_automower/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower",
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
- "requirements": ["aioautomower==2024.3.4"]
+ "requirements": ["aioautomower==2024.4.4"]
}
diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py
new file mode 100644
index 00000000000..e2e617b427b
--- /dev/null
+++ b/homeassistant/components/husqvarna_automower/number.py
@@ -0,0 +1,104 @@
+"""Creates the number entities for the mower."""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+import logging
+from typing import TYPE_CHECKING, Any
+
+from aioautomower.exceptions import ApiException
+from aioautomower.model import MowerAttributes
+from aioautomower.session import AutomowerSession
+
+from homeassistant.components.number import NumberEntity, NumberEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import AutomowerDataUpdateCoordinator
+from .entity import AutomowerBaseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class AutomowerNumberEntityDescription(NumberEntityDescription):
+ """Describes Automower number entity."""
+
+ exists_fn: Callable[[MowerAttributes], bool] = lambda _: True
+ value_fn: Callable[[MowerAttributes], int]
+ set_value_fn: Callable[[AutomowerSession, str, float], Awaitable[Any]]
+
+
+@callback
+def _async_get_cutting_height(data: MowerAttributes) -> int:
+ """Return the cutting height."""
+ if TYPE_CHECKING:
+ # Sensor does not get created if it is None
+ assert data.cutting_height is not None
+ return data.cutting_height
+
+
+NUMBER_TYPES: tuple[AutomowerNumberEntityDescription, ...] = (
+ AutomowerNumberEntityDescription(
+ key="cutting_height",
+ translation_key="cutting_height",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ native_min_value=1,
+ native_max_value=9,
+ exists_fn=lambda data: data.cutting_height is not None,
+ value_fn=_async_get_cutting_height,
+ set_value_fn=lambda session, mower_id, cheight: session.set_cutting_height(
+ mower_id, int(cheight)
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up number platform."""
+ coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ async_add_entities(
+ AutomowerNumberEntity(mower_id, coordinator, description)
+ for mower_id in coordinator.data
+ for description in NUMBER_TYPES
+ if description.exists_fn(coordinator.data[mower_id])
+ )
+
+
+class AutomowerNumberEntity(AutomowerBaseEntity, NumberEntity):
+ """Defining the AutomowerNumberEntity with AutomowerNumberEntityDescription."""
+
+ entity_description: AutomowerNumberEntityDescription
+
+ def __init__(
+ self,
+ mower_id: str,
+ coordinator: AutomowerDataUpdateCoordinator,
+ description: AutomowerNumberEntityDescription,
+ ) -> None:
+ """Set up AutomowerNumberEntity."""
+ super().__init__(mower_id, coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{mower_id}_{description.key}"
+
+ @property
+ def native_value(self) -> float:
+ """Return the state of the number."""
+ return self.entity_description.value_fn(self.mower_attributes)
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Change to new number value."""
+ try:
+ await self.entity_description.set_value_fn(
+ self.coordinator.api, self.mower_id, value
+ )
+ except ApiException as exception:
+ raise HomeAssistantError(
+ f"Command couldn't be sent to the command queue: {exception}"
+ ) from exception
diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py
index e4376a1bca5..67aac4a2046 100644
--- a/homeassistant/components/husqvarna_automower/select.py
+++ b/homeassistant/components/husqvarna_automower/select.py
@@ -1,6 +1,7 @@
"""Creates a select entity for the headlight of the mower."""
import logging
+from typing import cast
from aioautomower.exceptions import ApiException
from aioautomower.model import HeadlightModes
@@ -58,12 +59,14 @@ class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity):
@property
def current_option(self) -> str:
"""Return the current option for the entity."""
- return self.mower_attributes.headlight.mode.lower()
+ return cast(HeadlightModes, self.mower_attributes.headlight.mode).lower()
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
try:
- await self.coordinator.api.set_headlight_mode(self.mower_id, option.upper())
+ await self.coordinator.api.set_headlight_mode(
+ self.mower_id, cast(HeadlightModes, option.upper())
+ )
except ApiException as exception:
raise HomeAssistantError(
f"Command couldn't be sent to the command queue: {exception}"
diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py
index 10aec9b1536..6840708ed42 100644
--- a/homeassistant/components/husqvarna_automower/sensor.py
+++ b/homeassistant/components/husqvarna_automower/sensor.py
@@ -18,7 +18,6 @@ from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOf
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator
@@ -298,7 +297,7 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
key="next_start_timestamp",
translation_key="next_start_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
- value_fn=lambda data: dt_util.as_local(data.planner.next_start_datetime),
+ value_fn=lambda data: data.planner.next_start_datetime,
),
AutomowerSensorEntityDescription(
key="error",
diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json
index 0a2d3685c6e..b4c1c97cd68 100644
--- a/homeassistant/components/husqvarna_automower/strings.json
+++ b/homeassistant/components/husqvarna_automower/strings.json
@@ -37,6 +37,11 @@
"name": "Returning to dock"
}
},
+ "number": {
+ "cutting_height": {
+ "name": "Cutting height"
+ }
+ },
"select": {
"headlight_mode": {
"name": "Headlight mode",
diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py
index 541d4211e49..b4e14c42709 100644
--- a/homeassistant/components/hydrawise/__init__.py
+++ b/homeassistant/components/hydrawise/__init__.py
@@ -1,56 +1,29 @@
"""Support for Hydrawise cloud."""
-from pydrawise import legacy
-import voluptuous as vol
+from pydrawise import auth, client
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import (
- CONF_ACCESS_TOKEN,
- CONF_API_KEY,
- CONF_SCAN_INTERVAL,
- Platform,
-)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN, SCAN_INTERVAL
from .coordinator import HydrawiseDataUpdateCoordinator
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_ACCESS_TOKEN): cv.string,
- vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period,
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Hunter Hydrawise component."""
- if DOMAIN not in config:
- return True
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data={CONF_API_KEY: config[DOMAIN][CONF_ACCESS_TOKEN]},
- )
- )
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up Hydrawise from a config entry."""
- access_token = config_entry.data[CONF_API_KEY]
- hydrawise = legacy.LegacyHydrawiseAsync(access_token)
+ if CONF_USERNAME not in config_entry.data or CONF_PASSWORD not in config_entry.data:
+ # The GraphQL API requires username and password to authenticate. If either is
+ # missing, reauth is required.
+ raise ConfigEntryAuthFailed
+
+ hydrawise = client.Hydrawise(
+ auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD])
+ )
+
coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py
index e75cf56ac75..a93976b12e0 100644
--- a/homeassistant/components/hydrawise/binary_sensor.py
+++ b/homeassistant/components/hydrawise/binary_sensor.py
@@ -3,20 +3,15 @@
from __future__ import annotations
from pydrawise.schema import Zone
-import voluptuous as vol
from homeassistant.components.binary_sensor import (
- PLATFORM_SCHEMA,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
@@ -39,27 +34,6 @@ BINARY_SENSOR_KEYS: list[str] = [
desc.key for desc in (BINARY_SENSOR_STATUS, *BINARY_SENSOR_TYPES)
]
-# Deprecated since Home Assistant 2023.10.0
-# Can be removed completely in 2024.4.0
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSOR_KEYS): vol.All(
- cv.ensure_list, [vol.In(BINARY_SENSOR_KEYS)]
- )
- }
-)
-
-
-def setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up a sensor for a Hydrawise device."""
- # We don't need to trigger import flow from here as it's triggered from `__init__.py`
- return # pragma: no cover
-
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py
index cfaaefcd03a..1c2c1c5cf29 100644
--- a/homeassistant/components/hydrawise/config_flow.py
+++ b/homeassistant/components/hydrawise/config_flow.py
@@ -2,18 +2,16 @@
from __future__ import annotations
-from collections.abc import Callable
+from collections.abc import Callable, Mapping
from typing import Any
from aiohttp import ClientError
-from pydrawise import legacy
+from pydrawise import auth, client
+from pydrawise.exceptions import NotAuthorizedError
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_API_KEY
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
-from homeassistant.data_entry_flow import AbortFlow, FlowResultType
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
+from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import DOMAIN, LOGGER
@@ -23,14 +21,26 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
- async def _create_entry(
- self, api_key: str, *, on_failure: Callable[[str], ConfigFlowResult]
+ def __init__(self) -> None:
+ """Construct a ConfigFlow."""
+ self.reauth_entry: ConfigEntry | None = None
+
+ async def _create_or_update_entry(
+ self,
+ username: str,
+ password: str,
+ *,
+ on_failure: Callable[[str], ConfigFlowResult],
) -> ConfigFlowResult:
"""Create the config entry."""
- api = legacy.LegacyHydrawiseAsync(api_key)
+
+ # Verify that the provided credentials work."""
+ api = client.Hydrawise(auth.Auth(username, password))
try:
# Skip fetching zones to save on metered API calls.
- user = await api.get_user(fetch_zones=False)
+ user = await api.get_user()
+ except NotAuthorizedError:
+ return on_failure("invalid_auth")
except TimeoutError:
return on_failure("timeout_connect")
except ClientError as ex:
@@ -38,51 +48,33 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN):
return on_failure("cannot_connect")
await self.async_set_unique_id(f"hydrawise-{user.customer_id}")
- self._abort_if_unique_id_configured()
- return self.async_create_entry(title="Hydrawise", data={CONF_API_KEY: api_key})
+ if not self.reauth_entry:
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title="Hydrawise",
+ data={CONF_USERNAME: username, CONF_PASSWORD: password},
+ )
- def _import_issue(self, error_type: str) -> ConfigFlowResult:
- """Create an issue about a YAML import failure."""
- async_create_issue(
- self.hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{error_type}",
- breaks_in_ha_version="2024.4.0",
- is_fixable=False,
- severity=IssueSeverity.ERROR,
- translation_key="deprecated_yaml_import_issue",
- translation_placeholders={
- "error_type": error_type,
- "url": "/config/integrations/dashboard/add?domain=hydrawise",
- },
- )
- return self.async_abort(reason=error_type)
-
- def _deprecated_yaml_issue(self) -> None:
- """Create an issue about YAML deprecation."""
- async_create_issue(
- self.hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2024.4.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Hydrawise",
- },
+ self.hass.config_entries.async_update_entry(
+ self.reauth_entry,
+ data=self.reauth_entry.data
+ | {CONF_USERNAME: username, CONF_PASSWORD: password},
)
+ await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
+ return self.async_abort(reason="reauth_successful")
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial setup."""
if user_input is not None:
- api_key = user_input[CONF_API_KEY]
- return await self._create_entry(api_key, on_failure=self._show_form)
+ username = user_input[CONF_USERNAME]
+ password = user_input[CONF_PASSWORD]
+
+ return await self._create_or_update_entry(
+ username=username, password=password, on_failure=self._show_form
+ )
return self._show_form()
def _show_form(self, error_type: str | None = None) -> ConfigFlowResult:
@@ -91,21 +83,17 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = error_type
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
+ data_schema=vol.Schema(
+ {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
+ ),
errors=errors,
)
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import data from YAML."""
- try:
- result = await self._create_entry(
- import_data.get(CONF_API_KEY, ""),
- on_failure=self._import_issue,
- )
- except AbortFlow:
- self._deprecated_yaml_issue()
- raise
-
- if result["type"] == FlowResultType.CREATE_ENTRY:
- self._deprecated_yaml_issue()
- return result
+ async def async_step_reauth(
+ self, user_input: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth after updating config to username/password."""
+ self.reauth_entry = self.hass.config_entries.async_get_entry(
+ self.context["entry_id"]
+ )
+ return await self.async_step_user()
diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py
index eedeb4a07bc..84e9f979878 100644
--- a/homeassistant/components/hydrawise/sensor.py
+++ b/homeassistant/components/hydrawise/sensor.py
@@ -5,20 +5,16 @@ from __future__ import annotations
from datetime import datetime
from pydrawise.schema import Zone
-import voluptuous as vol
from homeassistant.components.sensor import (
- PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime
+from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -39,32 +35,10 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
)
SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES]
-
-# Deprecated since Home Assistant 2023.10.0
-# Can be removed completely in 2024.4.0
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All(
- cv.ensure_list, [vol.In(SENSOR_KEYS)]
- )
- }
-)
-
TWO_YEAR_SECONDS = 60 * 60 * 24 * 365 * 2
WATERING_TIME_ICON = "mdi:water-pump"
-def setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up a sensor for a Hydrawise device."""
- # We don't need to trigger import flow from here as it's triggered from `__init__.py`
- return # pragma: no cover
-
-
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json
index 8f079abcc7d..ee5cc0a541c 100644
--- a/homeassistant/components/hydrawise/strings.json
+++ b/homeassistant/components/hydrawise/strings.json
@@ -2,8 +2,11 @@
"config": {
"step": {
"user": {
+ "title": "Hydrawise Login",
+ "description": "Please provide the username and password for your Hydrawise cloud account:",
"data": {
- "api_key": "[%key:common::config_flow::data::api_key%]"
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
}
}
},
@@ -13,13 +16,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
- }
- },
- "issues": {
- "deprecated_yaml_import_issue": {
- "title": "The Hydrawise YAML configuration import failed",
- "description": "Configuring Hydrawise using YAML is being removed but there was an {error_type} error importing your YAML configuration.\n\nEnsure connection to Hydrawise works and restart Home Assistant to try again or remove the Hydrawise YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"entity": {
diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py
index 49106a5938a..2dc459e7dd4 100644
--- a/homeassistant/components/hydrawise/switch.py
+++ b/homeassistant/components/hydrawise/switch.py
@@ -6,28 +6,18 @@ from datetime import timedelta
from typing import Any
from pydrawise.schema import Zone
-import voluptuous as vol
from homeassistant.components.switch import (
- PLATFORM_SCHEMA,
SwitchDeviceClass,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
-from .const import (
- ALLOWED_WATERING_TIME,
- CONF_WATERING_TIME,
- DEFAULT_WATERING_TIME,
- DOMAIN,
-)
+from .const import DEFAULT_WATERING_TIME, DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
from .entity import HydrawiseEntity
@@ -46,30 +36,6 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES]
-# Deprecated since Home Assistant 2023.10.0
-# Can be removed completely in 2024.4.0
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCH_KEYS): vol.All(
- cv.ensure_list, [vol.In(SWITCH_KEYS)]
- ),
- vol.Optional(
- CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME.total_seconds() // 60
- ): vol.All(vol.In(ALLOWED_WATERING_TIME)),
- }
-)
-
-
-def setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up a sensor for a Hydrawise device."""
- # We don't need to trigger import flow from here as it's triggered from `__init__.py`
- return # pragma: no cover
-
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/hyperion/sensor.py b/homeassistant/components/hyperion/sensor.py
index f537c282686..ad972806ae5 100644
--- a/homeassistant/components/hyperion/sensor.py
+++ b/homeassistant/components/hyperion/sensor.py
@@ -191,13 +191,13 @@ class HyperionVisiblePrioritySensor(HyperionSensor):
if priority[KEY_COMPONENTID] == "COLOR":
state_value = priority[KEY_VALUE][KEY_RGB]
else:
- state_value = priority[KEY_OWNER]
+ state_value = priority.get(KEY_OWNER)
attrs = {
"component_id": priority[KEY_COMPONENTID],
"origin": priority[KEY_ORIGIN],
"priority": priority[KEY_PRIORITY],
- "owner": priority[KEY_OWNER],
+ "owner": priority.get(KEY_OWNER),
}
if priority[KEY_COMPONENTID] == "COLOR":
diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py
index 53d24044b53..c0123b89ee4 100644
--- a/homeassistant/components/imap/coordinator.py
+++ b/homeassistant/components/imap/coordinator.py
@@ -125,13 +125,13 @@ class ImapMessage:
return str(part.get_payload())
@property
- def headers(self) -> dict[str, tuple[str,]]:
+ def headers(self) -> dict[str, tuple[str, ...]]:
"""Get the email headers."""
- header_base: dict[str, tuple[str,]] = {}
+ header_base: dict[str, tuple[str, ...]] = {}
for key, value in self.email_message.items():
- header_instances: tuple[str,] = (str(value),)
+ header_instances: tuple[str, ...] = (str(value),)
if header_base.setdefault(key, header_instances) != header_instances:
- header_base[key] += header_instances # type: ignore[assignment]
+ header_base[key] += header_instances
return header_base
@property
diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py
index 52788066ba2..55b43ee8a1e 100644
--- a/homeassistant/components/input_text/__init__.py
+++ b/homeassistant/components/input_text/__init__.py
@@ -264,7 +264,7 @@ class InputText(collection.CollectionEntity, RestoreEntity):
return
state = await self.async_get_last_state()
- value: str | None = state and state.state # type: ignore[assignment]
+ value = state.state if state else None
# Check against None because value can be 0
if value is not None and self._minimum <= len(value) <= self._maximum:
diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py
index e741c7a5a27..4f9ac1f94b7 100644
--- a/homeassistant/components/iotawatt/coordinator.py
+++ b/homeassistant/components/iotawatt/coordinator.py
@@ -63,6 +63,7 @@ class IotawattUpdater(DataUpdateCoordinator):
self.entry.data.get(CONF_USERNAME),
self.entry.data.get(CONF_PASSWORD),
integratedInterval="d",
+ includeNonTotalSensors=False,
)
try:
is_authenticated = await api.connect()
diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json
index 5beaa1e318c..5fd178389d9 100644
--- a/homeassistant/components/iotawatt/manifest.json
+++ b/homeassistant/components/iotawatt/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/iotawatt",
"iot_class": "local_polling",
"loggers": ["iotawattpy"],
- "requirements": ["ha-iotawattpy==0.1.1"]
+ "requirements": ["ha-iotawattpy==0.1.2"]
}
diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py
index 69701534840..b9b269d9ca3 100644
--- a/homeassistant/components/isy994/light.py
+++ b/homeassistant/components/isy994/light.py
@@ -114,8 +114,5 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
if not (last_state := await self.async_get_last_state()):
return
- if (
- ATTR_LAST_BRIGHTNESS in last_state.attributes
- and last_state.attributes[ATTR_LAST_BRIGHTNESS]
- ):
- self._last_brightness = last_state.attributes[ATTR_LAST_BRIGHTNESS]
+ if last_brightness := last_state.attributes.get(ATTR_LAST_BRIGHTNESS):
+ self._last_brightness = last_brightness
diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py
index c24f06d7b19..de9fa805f02 100644
--- a/homeassistant/components/jellyfin/__init__.py
+++ b/homeassistant/components/jellyfin/__init__.py
@@ -73,6 +73,6 @@ async def async_remove_config_entry_device(
return not device_entry.identifiers.intersection(
(
(DOMAIN, coordinator.server_id),
- *((DOMAIN, id) for id in coordinator.device_ids),
+ *((DOMAIN, device_id) for device_id in coordinator.device_ids),
)
)
diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py
index 28e4cc995bb..8ce1fb46e3d 100644
--- a/homeassistant/components/jvc_projector/__init__.py
+++ b/homeassistant/components/jvc_projector/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import JvcProjectorDataUpdateCoordinator
-PLATFORMS = [Platform.BINARY_SENSOR, Platform.REMOTE, Platform.SENSOR]
+PLATFORMS = [Platform.BINARY_SENSOR, Platform.REMOTE, Platform.SELECT, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/jvc_projector/icons.json b/homeassistant/components/jvc_projector/icons.json
index c70ded78cb4..a0404b328e1 100644
--- a/homeassistant/components/jvc_projector/icons.json
+++ b/homeassistant/components/jvc_projector/icons.json
@@ -8,6 +8,11 @@
}
}
},
+ "select": {
+ "input": {
+ "default": "mdi:hdmi-port"
+ }
+ },
"sensor": {
"jvc_power_status": {
"default": "mdi:power-plug-off",
diff --git a/homeassistant/components/jvc_projector/select.py b/homeassistant/components/jvc_projector/select.py
new file mode 100644
index 00000000000..1395637fad1
--- /dev/null
+++ b/homeassistant/components/jvc_projector/select.py
@@ -0,0 +1,77 @@
+"""Select platform for the jvc_projector integration."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Final
+
+from jvcprojector import JvcProjector, const
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import JvcProjectorDataUpdateCoordinator
+from .const import DOMAIN
+from .entity import JvcProjectorEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class JvcProjectorSelectDescription(SelectEntityDescription):
+ """Describes JVC Projector select entities."""
+
+ command: Callable[[JvcProjector, str], Awaitable[None]]
+
+
+OPTIONS: Final[dict[str, dict[str, str]]] = {
+ "input": {const.HDMI1: const.REMOTE_HDMI_1, const.HDMI2: const.REMOTE_HDMI_2}
+}
+
+SELECTS: Final[list[JvcProjectorSelectDescription]] = [
+ JvcProjectorSelectDescription(
+ key="input",
+ translation_key="input",
+ options=list(OPTIONS["input"]),
+ command=lambda device, option: device.remote(OPTIONS["input"][option]),
+ )
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the JVC Projector platform from a config entry."""
+ coordinator: JvcProjectorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+
+ async_add_entities(
+ JvcProjectorSelectEntity(coordinator, description) for description in SELECTS
+ )
+
+
+class JvcProjectorSelectEntity(JvcProjectorEntity, SelectEntity):
+ """Representation of a JVC Projector select entity."""
+
+ entity_description: JvcProjectorSelectDescription
+
+ def __init__(
+ self,
+ coordinator: JvcProjectorDataUpdateCoordinator,
+ description: JvcProjectorSelectDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.unique_id}_{description.key}"
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the selected entity option to represent the entity state."""
+ return self.coordinator.data[self.entity_description.key]
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ await self.entity_description.command(self.coordinator.device, option)
diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json
index 9991fa1cf67..b89139cbab3 100644
--- a/homeassistant/components/jvc_projector/strings.json
+++ b/homeassistant/components/jvc_projector/strings.json
@@ -38,6 +38,15 @@
"name": "[%key:component::sensor::entity_component::power::name%]"
}
},
+ "select": {
+ "input": {
+ "name": "Input",
+ "state": {
+ "hdmi1": "HDMI 1",
+ "hdmi2": "HDMI 2"
+ }
+ }
+ },
"sensor": {
"jvc_power_status": {
"name": "Power status",
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index c84d53d6039..da68dc36a6d 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -197,11 +197,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
[
platform
for platform in SUPPORTED_PLATFORMS
- if platform in config and platform not in (Platform.SENSOR, Platform.NOTIFY)
+ if platform in config and platform is not Platform.SENSOR
],
)
- # set up notify platform, no entry support for notify component yet
+ # set up notify service for backwards compatibility - remove 2024.11
if NotifySchema.PLATFORM in config:
hass.async_create_task(
discovery.async_load_platform(
@@ -232,7 +232,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
platform
for platform in SUPPORTED_PLATFORMS
if platform in hass.data[DATA_KNX_CONFIG]
- and platform not in (Platform.SENSOR, Platform.NOTIFY)
+ and platform is not Platform.SENSOR
],
],
)
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index af0c6b8d01c..77f3db3f9f3 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -4,7 +4,7 @@
"after_dependencies": ["panel_custom"],
"codeowners": ["@Julius2342", "@farmio", "@marvin-w"],
"config_flow": true,
- "dependencies": ["file_upload", "websocket_api"],
+ "dependencies": ["file_upload", "repairs", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/knx",
"integration_type": "hub",
"iot_class": "local_push",
diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py
index 74ae86dc5d0..e208e4fd646 100644
--- a/homeassistant/components/knx/notify.py
+++ b/homeassistant/components/knx/notify.py
@@ -1,4 +1,4 @@
-"""Support for KNX/IP notification services."""
+"""Support for KNX/IP notifications."""
from __future__ import annotations
@@ -7,13 +7,16 @@ from typing import Any
from xknx import XKNX
from xknx.devices import Notification as XknxNotification
-from homeassistant.components.notify import BaseNotificationService
-from homeassistant.const import CONF_NAME, CONF_TYPE
+from homeassistant import config_entries
+from homeassistant.components.notify import BaseNotificationService, NotifyEntity
+from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
-from .schema import NotifySchema
+from .knx_entity import KnxEntity
+from .repairs import migrate_notify_issue
async def async_get_service(
@@ -25,16 +28,11 @@ async def async_get_service(
if discovery_info is None:
return None
- if platform_config := hass.data[DATA_KNX_CONFIG].get(NotifySchema.PLATFORM):
+ if platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.NOTIFY):
xknx: XKNX = hass.data[DOMAIN].xknx
notification_devices = [
- XknxNotification(
- xknx,
- name=device_config[CONF_NAME],
- group_address=device_config[KNX_ADDRESS],
- value_type=device_config[CONF_TYPE],
- )
+ _create_notification_instance(xknx, device_config)
for device_config in platform_config
]
return KNXNotificationService(notification_devices)
@@ -59,6 +57,7 @@ class KNXNotificationService(BaseNotificationService):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a notification to knx bus."""
+ migrate_notify_issue(self.hass)
if "target" in kwargs:
await self._async_send_to_device(message, kwargs["target"])
else:
@@ -74,3 +73,41 @@ class KNXNotificationService(BaseNotificationService):
for device in self.devices:
if device.name in names:
await device.set(message)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: config_entries.ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up notify(s) for KNX platform."""
+ xknx: XKNX = hass.data[DOMAIN].xknx
+ config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.NOTIFY]
+
+ async_add_entities(KNXNotify(xknx, entity_config) for entity_config in config)
+
+
+def _create_notification_instance(xknx: XKNX, config: ConfigType) -> XknxNotification:
+ """Return a KNX Notification to be used within XKNX."""
+ return XknxNotification(
+ xknx,
+ name=config[CONF_NAME],
+ group_address=config[KNX_ADDRESS],
+ value_type=config[CONF_TYPE],
+ )
+
+
+class KNXNotify(NotifyEntity, KnxEntity):
+ """Representation of a KNX notification entity."""
+
+ _device: XknxNotification
+
+ def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ """Initialize a KNX notification."""
+ super().__init__(_create_notification_instance(xknx, config))
+ self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
+ self._attr_unique_id = str(self._device.remote_value.group_address)
+
+ async def async_send_message(self, message: str) -> None:
+ """Send a notification to knx bus."""
+ await self._device.set(message)
diff --git a/homeassistant/components/knx/repairs.py b/homeassistant/components/knx/repairs.py
new file mode 100644
index 00000000000..f0a92850d36
--- /dev/null
+++ b/homeassistant/components/knx/repairs.py
@@ -0,0 +1,36 @@
+"""Repairs support for KNX."""
+
+from __future__ import annotations
+
+from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import issue_registry as ir
+
+from .const import DOMAIN
+
+
+@callback
+def migrate_notify_issue(hass: HomeAssistant) -> None:
+ """Create issue for notify service deprecation."""
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "migrate_notify",
+ breaks_in_ha_version="2024.11.0",
+ issue_domain=Platform.NOTIFY.value,
+ is_fixable=True,
+ is_persistent=True,
+ translation_key="migrate_notify",
+ severity=ir.IssueSeverity.WARNING,
+ )
+
+
+async def async_create_fix_flow(
+ hass: HomeAssistant,
+ issue_id: str,
+ data: dict[str, str | int | float | None] | None,
+) -> RepairsFlow:
+ """Create flow."""
+ assert issue_id == "migrate_notify"
+ return ConfirmRepairFlow()
diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py
index 39670b4f92b..462605c3985 100644
--- a/homeassistant/components/knx/schema.py
+++ b/homeassistant/components/knx/schema.py
@@ -750,6 +750,7 @@ class NotifySchema(KNXPlatformSchema):
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_TYPE, default="latin_1"): string_type_validator,
vol.Required(KNX_ADDRESS): ga_validator,
+ vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json
index 39b96dddf8f..a69ba106ffd 100644
--- a/homeassistant/components/knx/strings.json
+++ b/homeassistant/components/knx/strings.json
@@ -384,5 +384,18 @@
"name": "[%key:common::action::reload%]",
"description": "Reloads the KNX integration."
}
+ },
+ "issues": {
+ "migrate_notify": {
+ "title": "Migration of KNX notify service",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "description": "The KNX `notify` service has been migrated. New `notify` entities are available now.\n\nUpdate any automations to use the new `notify.send_message` exposed by these new entities. When this is done, fix this issue and restart Home Assistant.",
+ "title": "Disable legacy KNX notify service"
+ }
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py
index 7901b0bb3fa..412fe9ee3ce 100644
--- a/homeassistant/components/lamarzocco/coordinator.py
+++ b/homeassistant/components/lamarzocco/coordinator.py
@@ -147,7 +147,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
raise ConfigEntryAuthFailed(msg) from ex
except RequestNotSuccessful as ex:
_LOGGER.debug(ex, exc_info=True)
- raise UpdateFailed("Querying API failed. Error: %s" % ex) from ex
+ raise UpdateFailed(f"Querying API failed. Error: {ex}") from ex
def async_get_ble_device(self) -> BLEDevice | None:
"""Get a Bleak Client for the machine."""
diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py
index e21d8eaba58..16e743e00b5 100644
--- a/homeassistant/components/linear_garage_door/__init__.py
+++ b/homeassistant/components/linear_garage_door/__init__.py
@@ -15,7 +15,7 @@ PLATFORMS: list[Platform] = [Platform.COVER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Linear Garage Door from a config entry."""
- coordinator = LinearUpdateCoordinator(hass, entry)
+ coordinator = LinearUpdateCoordinator(hass)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py
index b771b552b62..91ff0165163 100644
--- a/homeassistant/components/linear_garage_door/coordinator.py
+++ b/homeassistant/components/linear_garage_door/coordinator.py
@@ -2,9 +2,11 @@
from __future__ import annotations
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
from datetime import timedelta
import logging
-from typing import Any
+from typing import Any, TypeVar
from linear_garage_door import Linear
from linear_garage_door.errors import InvalidLoginError
@@ -17,46 +19,58 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
+_T = TypeVar("_T")
-class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
+
+@dataclass
+class LinearDevice:
+ """Linear device dataclass."""
+
+ name: str
+ subdevices: dict[str, dict[str, str]]
+
+
+class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, LinearDevice]]):
"""DataUpdateCoordinator for Linear."""
- _email: str
- _password: str
- _device_id: str
- _site_id: str
- _devices: list[dict[str, list[str] | str]] | None
- _linear: Linear
+ _devices: list[dict[str, Any]] | None = None
+ config_entry: ConfigEntry
- def __init__(
- self,
- hass: HomeAssistant,
- entry: ConfigEntry,
- ) -> None:
+ def __init__(self, hass: HomeAssistant) -> None:
"""Initialize DataUpdateCoordinator for Linear."""
- self._email = entry.data["email"]
- self._password = entry.data["password"]
- self._device_id = entry.data["device_id"]
- self._site_id = entry.data["site_id"]
- self._devices = None
-
super().__init__(
hass,
_LOGGER,
name="Linear Garage Door",
update_interval=timedelta(seconds=60),
)
+ self.site_id = self.config_entry.data["site_id"]
- async def _async_update_data(self) -> dict[str, Any]:
+ async def _async_update_data(self) -> dict[str, LinearDevice]:
"""Get the data for Linear."""
- linear = Linear()
+ async def update_data(linear: Linear) -> dict[str, Any]:
+ if not self._devices:
+ self._devices = await linear.get_devices(self.site_id)
+ data = {}
+
+ for device in self._devices:
+ device_id = str(device["id"])
+ state = await linear.get_device_state(device_id)
+ data[device_id] = LinearDevice(device["name"], state)
+ return data
+
+ return await self.execute(update_data)
+
+ async def execute(self, func: Callable[[Linear], Awaitable[_T]]) -> _T:
+ """Execute an API call."""
+ linear = Linear()
try:
await linear.login(
- email=self._email,
- password=self._password,
- device_id=self._device_id,
+ email=self.config_entry.data["email"],
+ password=self.config_entry.data["password"],
+ device_id=self.config_entry.data["device_id"],
client_session=async_get_clientsession(self.hass),
)
except InvalidLoginError as err:
@@ -66,17 +80,6 @@ class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
):
raise ConfigEntryAuthFailed from err
raise ConfigEntryNotReady from err
-
- if not self._devices:
- self._devices = await linear.get_devices(self._site_id)
-
- data = {}
-
- for device in self._devices:
- device_id = str(device["id"])
- state = await linear.get_device_state(device_id)
- data[device_id] = {"name": device["name"], "subdevices": state}
-
+ result = await func(linear)
await linear.close()
-
- return data
+ return result
diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py
index 3474e9d3acb..b3d720e531a 100644
--- a/homeassistant/components/linear_garage_door/cover.py
+++ b/homeassistant/components/linear_garage_door/cover.py
@@ -3,8 +3,6 @@
from datetime import timedelta
from typing import Any
-from linear_garage_door import Linear
-
from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
@@ -12,13 +10,12 @@ from homeassistant.components.cover import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
-from .coordinator import LinearUpdateCoordinator
+from .coordinator import LinearDevice, LinearUpdateCoordinator
SUPPORTED_SUBDEVICES = ["GDO"]
PARALLEL_UPDATES = 1
@@ -32,118 +29,89 @@ async def async_setup_entry(
) -> None:
"""Set up Linear Garage Door cover."""
coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
- data = coordinator.data
- device_list: list[LinearCoverEntity] = []
-
- for device_id in data:
- device_list.extend(
- LinearCoverEntity(
- device_id=device_id,
- device_name=data[device_id]["name"],
- subdevice=subdev,
- config_entry=config_entry,
- coordinator=coordinator,
- )
- for subdev in data[device_id]["subdevices"]
- if subdev in SUPPORTED_SUBDEVICES
- )
- async_add_entities(device_list)
+ async_add_entities(
+ LinearCoverEntity(coordinator, device_id, sub_device_id)
+ for device_id, device_data in coordinator.data.items()
+ for sub_device_id in device_data.subdevices
+ if sub_device_id in SUPPORTED_SUBDEVICES
+ )
class LinearCoverEntity(CoordinatorEntity[LinearUpdateCoordinator], CoverEntity):
"""Representation of a Linear cover."""
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
+ _attr_has_entity_name = True
+ _attr_name = None
+ _attr_device_class = CoverDeviceClass.GARAGE
def __init__(
self,
- device_id: str,
- device_name: str,
- subdevice: str,
- config_entry: ConfigEntry,
coordinator: LinearUpdateCoordinator,
+ device_id: str,
+ sub_device_id: str,
) -> None:
"""Init with device ID and name."""
super().__init__(coordinator)
-
- self._attr_has_entity_name = True
- self._attr_name = None
self._device_id = device_id
- self._device_name = device_name
- self._subdevice = subdevice
- self._attr_device_class = CoverDeviceClass.GARAGE
- self._attr_unique_id = f"{device_id}-{subdevice}"
- self._config_entry = config_entry
-
- def _get_data(self, data_property: str) -> str:
- """Get a property of the subdevice."""
- return str(
- self.coordinator.data[self._device_id]["subdevices"][self._subdevice].get(
- data_property
- )
- )
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return device info of a garage door."""
- return DeviceInfo(
- identifiers={(DOMAIN, self._device_id)},
- name=self._device_name,
+ self._sub_device_id = sub_device_id
+ self._attr_unique_id = f"{device_id}-{sub_device_id}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, sub_device_id)},
+ name=self.linear_device.name,
manufacturer="Linear",
model="Garage Door Opener",
)
+ @property
+ def linear_device(self) -> LinearDevice:
+ """Return the Linear device."""
+ return self.coordinator.data[self._device_id]
+
+ @property
+ def sub_device(self) -> dict[str, str]:
+ """Return the subdevice."""
+ return self.linear_device.subdevices[self._sub_device_id]
+
@property
def is_closed(self) -> bool:
"""Return if cover is closed."""
- return bool(self._get_data("Open_B") == "false")
+ return self.sub_device.get("Open_B") == "false"
@property
def is_opened(self) -> bool:
"""Return if cover is open."""
- return bool(self._get_data("Open_B") == "true")
+ return self.sub_device.get("Open_B") == "true"
@property
def is_opening(self) -> bool:
"""Return if cover is opening."""
- return bool(self._get_data("Opening_P") == "0")
+ return self.sub_device.get("Opening_P") == "0"
@property
def is_closing(self) -> bool:
"""Return if cover is closing."""
- return bool(self._get_data("Opening_P") == "100")
+ return self.sub_device.get("Opening_P") == "100"
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the garage door."""
if self.is_closed:
return
- linear = Linear()
-
- await linear.login(
- email=self._config_entry.data["email"],
- password=self._config_entry.data["password"],
- device_id=self._config_entry.data["device_id"],
- client_session=async_get_clientsession(self.hass),
+ await self.coordinator.execute(
+ lambda linear: linear.operate_device(
+ self._device_id, self._sub_device_id, "Close"
+ )
)
- await linear.operate_device(self._device_id, self._subdevice, "Close")
- await linear.close()
-
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the garage door."""
if self.is_opened:
return
- linear = Linear()
-
- await linear.login(
- email=self._config_entry.data["email"],
- password=self._config_entry.data["password"],
- device_id=self._config_entry.data["device_id"],
- client_session=async_get_clientsession(self.hass),
+ await self.coordinator.execute(
+ lambda linear: linear.operate_device(
+ self._device_id, self._sub_device_id, "Open"
+ )
)
-
- await linear.operate_device(self._device_id, self._subdevice, "Open")
- await linear.close()
diff --git a/homeassistant/components/linear_garage_door/diagnostics.py b/homeassistant/components/linear_garage_door/diagnostics.py
index fc4906daa77..21414f02f87 100644
--- a/homeassistant/components/linear_garage_door/diagnostics.py
+++ b/homeassistant/components/linear_garage_door/diagnostics.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
@@ -23,5 +24,8 @@ async def async_get_config_entry_diagnostics(
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
- "coordinator_data": coordinator.data,
+ "coordinator_data": {
+ device_id: asdict(device_data)
+ for device_id, device_data in coordinator.data.items()
+ },
}
diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json
index 66ade5f356c..88396f9f9c1 100644
--- a/homeassistant/components/litterrobot/manifest.json
+++ b/homeassistant/components/litterrobot/manifest.json
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
- "requirements": ["pylitterbot==2023.4.11"]
+ "requirements": ["pylitterbot==2023.5.0"]
}
diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json
index 1c13970503d..b1c7d6a3a34 100644
--- a/homeassistant/components/local_calendar/manifest.json
+++ b/homeassistant/components/local_calendar/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
- "requirements": ["ical==7.0.3"]
+ "requirements": ["ical==8.0.0"]
}
diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json
index 3bcb8af9f43..44c76a56a8f 100644
--- a/homeassistant/components/local_todo/manifest.json
+++ b/homeassistant/components/local_todo/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
- "requirements": ["ical==7.0.3"]
+ "requirements": ["ical==8.0.0"]
}
diff --git a/homeassistant/components/lock/icons.json b/homeassistant/components/lock/icons.json
index 1bf48f2ab40..0ce2e70d372 100644
--- a/homeassistant/components/lock/icons.json
+++ b/homeassistant/components/lock/icons.json
@@ -5,7 +5,7 @@
"state": {
"jammed": "mdi:lock-alert",
"locking": "mdi:lock-clock",
- "unlocked": "mdi:lock-open",
+ "unlocked": "mdi:lock-open-variant",
"unlocking": "mdi:lock-clock"
}
}
@@ -13,6 +13,6 @@
"services": {
"lock": "mdi:lock",
"open": "mdi:door-open",
- "unlock": "mdi:lock-open"
+ "unlock": "mdi:lock-open-variant"
}
}
diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py
index 517eb4c8350..828182547c2 100644
--- a/homeassistant/components/lutron/__init__.py
+++ b/homeassistant/components/lutron/__init__.py
@@ -3,31 +3,25 @@
from dataclasses import dataclass
import logging
-from pylutron import Button, Keypad, Led, Lutron, LutronEvent, OccupancyGroup, Output
+from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_ID,
- CONF_HOST,
- CONF_PASSWORD,
- CONF_USERNAME,
- Platform,
-)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr, entity_registry as er
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
-from homeassistant.util import slugify
from .const import DOMAIN
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.COVER,
+ Platform.EVENT,
Platform.FAN,
Platform.LIGHT,
Platform.SCENE,
@@ -105,69 +99,13 @@ async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool:
return True
-class LutronButton:
- """Representation of a button on a Lutron keypad.
-
- This is responsible for firing events as keypad buttons are pressed
- (and possibly released, depending on the button type). It is not
- represented as an entity; it simply fires events.
- """
-
- def __init__(
- self, hass: HomeAssistant, area_name: str, keypad: Keypad, button: Button
- ) -> None:
- """Register callback for activity on the button."""
- name = f"{keypad.name}: {button.name}"
- if button.name == "Unknown Button":
- name += f" {button.number}"
- self._hass = hass
- self._has_release_event = (
- button.button_type is not None and "RaiseLower" in button.button_type
- )
- self._id = slugify(name)
- self._keypad = keypad
- self._area_name = area_name
- self._button_name = button.name
- self._button = button
- self._event = "lutron_event"
- self._full_id = slugify(f"{area_name} {name}")
- self._uuid = button.uuid
-
- button.subscribe(self.button_callback, None)
-
- def button_callback(
- self, _button: Button, _context: None, event: LutronEvent, _params: dict
- ) -> None:
- """Fire an event about a button being pressed or released."""
- # Events per button type:
- # RaiseLower -> pressed/released
- # SingleAction -> single
- action = None
- if self._has_release_event:
- if event == Button.Event.PRESSED:
- action = "pressed"
- else:
- action = "released"
- elif event == Button.Event.PRESSED:
- action = "single"
-
- if action:
- data = {
- ATTR_ID: self._id,
- ATTR_ACTION: action,
- ATTR_FULL_ID: self._full_id,
- ATTR_UUID: self._uuid,
- }
- self._hass.bus.fire(self._event, data)
-
-
@dataclass(slots=True, kw_only=True)
class LutronData:
"""Storage class for platform global data."""
client: Lutron
binary_sensors: list[tuple[str, OccupancyGroup]]
- buttons: list[LutronButton]
+ buttons: list[tuple[str, Keypad, Button]]
covers: list[tuple[str, Output]]
fans: list[tuple[str, Output]]
lights: list[tuple[str, Output]]
@@ -273,8 +211,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
led.legacy_uuid,
entry_data.client.guid,
)
-
- entry_data.buttons.append(LutronButton(hass, area.name, keypad, button))
+ if button.button_type:
+ entry_data.buttons.append((area.name, keypad, button))
if area.occupancy_group is not None:
entry_data.binary_sensors.append((area.name, area.occupancy_group))
platform = Platform.BINARY_SENSOR
diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py
new file mode 100644
index 00000000000..710f942a006
--- /dev/null
+++ b/homeassistant/components/lutron/event.py
@@ -0,0 +1,109 @@
+"""Support for Lutron events."""
+
+from enum import StrEnum
+
+from pylutron import Button, Keypad, Lutron, LutronEvent
+
+from homeassistant.components.event import EventEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_ID
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util import slugify
+
+from . import ATTR_ACTION, ATTR_FULL_ID, ATTR_UUID, DOMAIN, LutronData
+from .entity import LutronKeypad
+
+
+class LutronEventType(StrEnum):
+ """Lutron event types."""
+
+ SINGLE_PRESS = "single_press"
+ PRESS = "press"
+ RELEASE = "release"
+
+
+LEGACY_EVENT_TYPES: dict[LutronEventType, str] = {
+ LutronEventType.SINGLE_PRESS: "single",
+ LutronEventType.PRESS: "pressed",
+ LutronEventType.RELEASE: "released",
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Lutron event platform."""
+ entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id]
+
+ async_add_entities(
+ LutronEventEntity(area_name, keypad, button, entry_data.client)
+ for area_name, keypad, button in entry_data.buttons
+ )
+
+
+class LutronEventEntity(LutronKeypad, EventEntity):
+ """Representation of a Lutron keypad button."""
+
+ _attr_translation_key = "button"
+
+ def __init__(
+ self,
+ area_name: str,
+ keypad: Keypad,
+ button: Button,
+ controller: Lutron,
+ ) -> None:
+ """Initialize the button."""
+ super().__init__(area_name, button, controller, keypad)
+ if (name := button.name) == "Unknown Button":
+ name += f" {button.number}"
+ self._attr_name = name
+ self._has_release_event = (
+ button.button_type is not None and "RaiseLower" in button.button_type
+ )
+ if self._has_release_event:
+ self._attr_event_types = [LutronEventType.PRESS, LutronEventType.RELEASE]
+ else:
+ self._attr_event_types = [LutronEventType.SINGLE_PRESS]
+
+ self._full_id = slugify(f"{area_name} {name}")
+ self._id = slugify(name)
+
+ async def async_added_to_hass(self) -> None:
+ """Register callbacks."""
+ await super().async_added_to_hass()
+ self._lutron_device.subscribe(self.handle_event, None)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Unregister callbacks."""
+ await super().async_will_remove_from_hass()
+ # Temporary solution until https://github.com/thecynic/pylutron/pull/93 gets merged
+ self._lutron_device._subscribers.remove((self.handle_event, None)) # pylint: disable=protected-access
+
+ @callback
+ def handle_event(
+ self, button: Button, _context: None, event: LutronEvent, _params: dict
+ ) -> None:
+ """Handle received event."""
+ action: LutronEventType | None = None
+ if self._has_release_event:
+ if event == Button.Event.PRESSED:
+ action = LutronEventType.PRESS
+ else:
+ action = LutronEventType.RELEASE
+ elif event == Button.Event.PRESSED:
+ action = LutronEventType.SINGLE_PRESS
+
+ if action:
+ data = {
+ ATTR_ID: self._id,
+ ATTR_ACTION: LEGACY_EVENT_TYPES[action],
+ ATTR_FULL_ID: self._full_id,
+ ATTR_UUID: button.uuid,
+ }
+ self.hass.bus.fire("lutron_event", data)
+ self._trigger_event(action)
+ self.async_write_ha_state()
diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json
index efa0a35d81a..0212c8845d5 100644
--- a/homeassistant/components/lutron/strings.json
+++ b/homeassistant/components/lutron/strings.json
@@ -22,6 +22,21 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
},
+ "entity": {
+ "event": {
+ "button": {
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "single_press": "Single press",
+ "press": "Press",
+ "release": "Release"
+ }
+ }
+ }
+ }
+ }
+ },
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The Lutron YAML configuration import cannot connect to server",
diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py
index 228a012a04f..56b768c26a2 100644
--- a/homeassistant/components/media_extractor/__init__.py
+++ b/homeassistant/components/media_extractor/__init__.py
@@ -55,7 +55,7 @@ CONFIG_SCHEMA = vol.Schema(
)
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the media extractor service."""
async def extract_media_url(call: ServiceCall) -> ServiceResponse:
@@ -114,7 +114,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
supports_response=SupportsResponse.ONLY,
)
- hass.services.register(
+ hass.services.async_register(
DOMAIN,
SERVICE_PLAY_MEDIA,
play_media,
@@ -278,9 +278,9 @@ def get_best_stream_youtube(formats: list[dict[str, Any]]) -> str:
return get_best_stream(
[
- format
- for format in formats
- if format.get("acodec", "none") != "none"
- and format.get("vcodec", "none") != "none"
+ stream_format
+ for stream_format in formats
+ if stream_format.get("acodec", "none") != "none"
+ and stream_format.get("vcodec", "none") != "none"
]
)
diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py
index 0d1848e0d8e..bd7eed8235c 100644
--- a/homeassistant/components/modbus/modbus.py
+++ b/homeassistant/components/modbus/modbus.py
@@ -34,6 +34,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.typing import ConfigType
@@ -234,6 +235,18 @@ async def async_modbus_setup(
async def async_restart_hub(service: ServiceCall) -> None:
"""Restart Modbus hub."""
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_restart",
+ breaks_in_ha_version="2024.11.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_restart",
+ )
+ _LOGGER.warning(
+ "`modbus.restart`: is deprecated and will be removed in version 2024.11"
+ )
async_dispatcher_send(hass, SIGNAL_START_ENTITY)
hub = hub_collect[service.data[ATTR_HUB]]
await hub.async_restart()
diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json
index 72d7a3ec5f1..f89f9a97d52 100644
--- a/homeassistant/components/modbus/strings.json
+++ b/homeassistant/components/modbus/strings.json
@@ -97,6 +97,10 @@
"no_entities": {
"title": "Modbus {sub_1} contain no entities, entry not loaded.",
"description": "Please add at least one entity to Modbus {sub_1} in your configuration.yaml file and restart Home Assistant to fix this issue."
+ },
+ "deprecated_restart": {
+ "title": "`modbus.restart` is being removed",
+ "description": "Please use reload yaml via the developer tools in the UI instead of via the `modbus.restart` service."
}
}
}
diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py
index 28cb7d0944b..cc1ae3ddce1 100644
--- a/homeassistant/components/mqtt/__init__.py
+++ b/homeassistant/components/mqtt/__init__.py
@@ -265,7 +265,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
conf: dict[str, Any]
mqtt_data: MqttData
- async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
+ async def _setup_client(
+ client_available: asyncio.Future[bool],
+ ) -> tuple[MqttData, dict[str, Any]]:
"""Set up the MQTT client."""
# Fetch configuration
conf = dict(entry.data)
@@ -294,7 +296,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.add_update_listener(_async_config_entry_updated)
)
- await mqtt_data.client.async_connect()
+ await mqtt_data.client.async_connect(client_available)
return (mqtt_data, conf)
client_available: asyncio.Future[bool]
@@ -303,13 +305,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
else:
client_available = hass.data[DATA_MQTT_AVAILABLE]
- setup_ok: bool = False
- try:
- mqtt_data, conf = await _setup_client()
- setup_ok = True
- finally:
- if not client_available.done():
- client_available.set_result(setup_ok)
+ mqtt_data, conf = await _setup_client(client_available)
async def async_publish_service(call: ServiceCall) -> None:
"""Handle MQTT publish service calls."""
diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py
index 978123e169c..f01b8e80b3d 100644
--- a/homeassistant/components/mqtt/client.py
+++ b/homeassistant/components/mqtt/client.py
@@ -3,12 +3,14 @@
from __future__ import annotations
import asyncio
-from collections.abc import Callable, Coroutine, Iterable
+from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable
+import contextlib
from dataclasses import dataclass
-from functools import lru_cache
+from functools import lru_cache, partial
from itertools import chain, groupby
import logging
from operator import attrgetter
+import socket
import ssl
import time
from typing import TYPE_CHECKING, Any
@@ -35,10 +37,10 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
-from homeassistant.util import dt as dt_util
+from homeassistant.util.async_ import create_eager_task
from homeassistant.util.logging import catch_log_exception
from .const import (
@@ -92,6 +94,9 @@ INITIAL_SUBSCRIBE_COOLDOWN = 1.0
SUBSCRIBE_COOLDOWN = 0.1
UNSUBSCRIBE_COOLDOWN = 0.1
TIMEOUT_ACK = 10
+RECONNECT_INTERVAL_SECONDS = 10
+
+SocketType = socket.socket | ssl.SSLSocket | Any
SubscribePayloadType = str | bytes # Only bytes if encoding is None
@@ -258,7 +263,9 @@ class MqttClientSetup:
# However, that feature is not mandatory so we generate our own.
client_id = mqtt.base62(uuid.uuid4().int, padding=22)
transport = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
- self._client = mqtt.Client(client_id, protocol=proto, transport=transport)
+ self._client = mqtt.Client(
+ client_id, protocol=proto, transport=transport, reconnect_on_failure=False
+ )
# Enable logging
self._client.enable_logger()
@@ -345,7 +352,7 @@ class EnsureJobAfterCooldown:
return
self._async_cancel_timer()
- self._task = asyncio.create_task(self._async_job())
+ self._task = create_eager_task(self._async_job())
self._task.add_done_callback(self._async_task_done)
@callback
@@ -404,12 +411,17 @@ class MQTT:
self._ha_started = asyncio.Event()
self._cleanup_on_unload: list[Callable[[], None]] = []
- self._paho_lock = asyncio.Lock() # Prevents parallel calls to the MQTT client
+ self._connection_lock = asyncio.Lock()
self._pending_operations: dict[int, asyncio.Event] = {}
self._pending_operations_condition = asyncio.Condition()
self._subscribe_debouncer = EnsureJobAfterCooldown(
INITIAL_SUBSCRIBE_COOLDOWN, self._async_perform_subscriptions
)
+ self._misc_task: asyncio.Task | None = None
+ self._reconnect_task: asyncio.Task | None = None
+ self._should_reconnect: bool = True
+ self._available_future: asyncio.Future[bool] | None = None
+
self._max_qos: dict[str, int] = {} # topic, max qos
self._pending_subscriptions: dict[str, int] = {} # topic, qos
self._unsubscribe_debouncer = EnsureJobAfterCooldown(
@@ -456,25 +468,140 @@ class MQTT:
while self._cleanup_on_unload:
self._cleanup_on_unload.pop()()
+ @contextlib.asynccontextmanager
+ async def _async_connect_in_executor(self) -> AsyncGenerator[None, None]:
+ # While we are connecting in the executor we need to
+ # handle on_socket_open and on_socket_register_write
+ # in the executor as well.
+ mqttc = self._mqttc
+ try:
+ mqttc.on_socket_open = self._on_socket_open
+ mqttc.on_socket_register_write = self._on_socket_register_write
+ yield
+ finally:
+ # Once the executor job is done, we can switch back to
+ # handling these in the event loop.
+ mqttc.on_socket_open = self._async_on_socket_open
+ mqttc.on_socket_register_write = self._async_on_socket_register_write
+
def init_client(self) -> None:
"""Initialize paho client."""
- self._mqttc = MqttClientSetup(self.conf).client
- self._mqttc.on_connect = self._mqtt_on_connect
- self._mqttc.on_disconnect = self._mqtt_on_disconnect
- self._mqttc.on_message = self._mqtt_on_message
- self._mqttc.on_publish = self._mqtt_on_callback
- self._mqttc.on_subscribe = self._mqtt_on_callback
- self._mqttc.on_unsubscribe = self._mqtt_on_callback
+ mqttc = MqttClientSetup(self.conf).client
+ # on_socket_unregister_write and _async_on_socket_close
+ # are only ever called in the event loop
+ mqttc.on_socket_close = self._async_on_socket_close
+ mqttc.on_socket_unregister_write = self._async_on_socket_unregister_write
+
+ # These will be called in the event loop
+ mqttc.on_connect = self._async_mqtt_on_connect
+ mqttc.on_disconnect = self._async_mqtt_on_disconnect
+ mqttc.on_message = self._async_mqtt_on_message
+ mqttc.on_publish = self._async_mqtt_on_callback
+ mqttc.on_subscribe = self._async_mqtt_on_callback
+ mqttc.on_unsubscribe = self._async_mqtt_on_callback
if will := self.conf.get(CONF_WILL_MESSAGE, DEFAULT_WILL):
will_message = PublishMessage(**will)
- self._mqttc.will_set(
+ mqttc.will_set(
topic=will_message.topic,
payload=will_message.payload,
qos=will_message.qos,
retain=will_message.retain,
)
+ self._mqttc = mqttc
+
+ async def _misc_loop(self) -> None:
+ """Start the MQTT client misc loop."""
+ # pylint: disable=import-outside-toplevel
+ import paho.mqtt.client as mqtt
+
+ while self._mqttc.loop_misc() == mqtt.MQTT_ERR_SUCCESS:
+ await asyncio.sleep(1)
+
+ @callback
+ def _async_reader_callback(self, client: mqtt.Client) -> None:
+ """Handle reading data from the socket."""
+ if (status := client.loop_read()) != 0:
+ self._async_on_disconnect(status)
+
+ @callback
+ def _async_start_misc_loop(self) -> None:
+ """Start the misc loop."""
+ if self._misc_task is None or self._misc_task.done():
+ _LOGGER.debug("%s: Starting client misc loop", self.config_entry.title)
+ self._misc_task = self.config_entry.async_create_background_task(
+ self.hass, self._misc_loop(), name="mqtt misc loop"
+ )
+
+ def _on_socket_open(
+ self, client: mqtt.Client, userdata: Any, sock: SocketType
+ ) -> None:
+ """Handle socket open."""
+ self.loop.call_soon_threadsafe(
+ self._async_on_socket_open, client, userdata, sock
+ )
+
+ @callback
+ def _async_on_socket_open(
+ self, client: mqtt.Client, userdata: Any, sock: SocketType
+ ) -> None:
+ """Handle socket open."""
+ fileno = sock.fileno()
+ _LOGGER.debug("%s: connection opened %s", self.config_entry.title, fileno)
+ if fileno > -1:
+ self.loop.add_reader(sock, partial(self._async_reader_callback, client))
+ self._async_start_misc_loop()
+
+ @callback
+ def _async_on_socket_close(
+ self, client: mqtt.Client, userdata: Any, sock: SocketType
+ ) -> None:
+ """Handle socket close."""
+ fileno = sock.fileno()
+ _LOGGER.debug("%s: connection closed %s", self.config_entry.title, fileno)
+ # If socket close is called before the connect
+ # result is set make sure the first connection result is set
+ self._async_connection_result(False)
+ if fileno > -1:
+ self.loop.remove_reader(sock)
+ if self._misc_task is not None and not self._misc_task.done():
+ self._misc_task.cancel()
+
+ @callback
+ def _async_writer_callback(self, client: mqtt.Client) -> None:
+ """Handle writing data to the socket."""
+ if (status := client.loop_write()) != 0:
+ self._async_on_disconnect(status)
+
+ def _on_socket_register_write(
+ self, client: mqtt.Client, userdata: Any, sock: SocketType
+ ) -> None:
+ """Register the socket for writing."""
+ self.loop.call_soon_threadsafe(
+ self._async_on_socket_register_write, client, None, sock
+ )
+
+ @callback
+ def _async_on_socket_register_write(
+ self, client: mqtt.Client, userdata: Any, sock: SocketType
+ ) -> None:
+ """Register the socket for writing."""
+ fileno = sock.fileno()
+ _LOGGER.debug("%s: register write %s", self.config_entry.title, fileno)
+ if fileno > -1:
+ self.loop.add_writer(sock, partial(self._async_writer_callback, client))
+
+ @callback
+ def _async_on_socket_unregister_write(
+ self, client: mqtt.Client, userdata: Any, sock: SocketType
+ ) -> None:
+ """Unregister the socket for writing."""
+ fileno = sock.fileno()
+ _LOGGER.debug("%s: unregister write %s", self.config_entry.title, fileno)
+ if fileno > -1:
+ self.loop.remove_writer(sock)
+
def _is_active_subscription(self, topic: str) -> bool:
"""Check if a topic has an active subscription."""
return topic in self._simple_subscriptions or any(
@@ -485,10 +612,7 @@ class MQTT:
self, topic: str, payload: PublishPayloadType, qos: int, retain: bool
) -> None:
"""Publish a MQTT message."""
- async with self._paho_lock:
- msg_info = await self.hass.async_add_executor_job(
- self._mqttc.publish, topic, payload, qos, retain
- )
+ msg_info = self._mqttc.publish(topic, payload, qos, retain)
_LOGGER.debug(
"Transmitting%s message on %s: '%s', mid: %s, qos: %s",
" retained" if retain else "",
@@ -500,37 +624,71 @@ class MQTT:
_raise_on_error(msg_info.rc)
await self._wait_for_mid(msg_info.mid)
- async def async_connect(self) -> None:
+ async def async_connect(self, client_available: asyncio.Future[bool]) -> None:
"""Connect to the host. Does not process messages yet."""
# pylint: disable-next=import-outside-toplevel
import paho.mqtt.client as mqtt
result: int | None = None
+ self._available_future = client_available
+ self._should_reconnect = True
try:
- result = await self.hass.async_add_executor_job(
- self._mqttc.connect,
- self.conf[CONF_BROKER],
- self.conf.get(CONF_PORT, DEFAULT_PORT),
- self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE),
- )
+ async with self._connection_lock, self._async_connect_in_executor():
+ result = await self.hass.async_add_executor_job(
+ self._mqttc.connect,
+ self.conf[CONF_BROKER],
+ self.conf.get(CONF_PORT, DEFAULT_PORT),
+ self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE),
+ )
except OSError as err:
_LOGGER.error("Failed to connect to MQTT server due to exception: %s", err)
+ self._async_connection_result(False)
+ finally:
+ if result is not None and result != 0:
+ if result is not None:
+ _LOGGER.error(
+ "Failed to connect to MQTT server: %s",
+ mqtt.error_string(result),
+ )
+ self._async_connection_result(False)
- if result is not None and result != 0:
- _LOGGER.error(
- "Failed to connect to MQTT server: %s", mqtt.error_string(result)
+ @callback
+ def _async_connection_result(self, connected: bool) -> None:
+ """Handle a connection result."""
+ if self._available_future and not self._available_future.done():
+ self._available_future.set_result(connected)
+
+ if connected:
+ self._async_cancel_reconnect()
+ elif self._should_reconnect and not self._reconnect_task:
+ self._reconnect_task = self.config_entry.async_create_background_task(
+ self.hass, self._reconnect_loop(), "mqtt reconnect loop"
)
- self._mqttc.loop_start()
+ @callback
+ def _async_cancel_reconnect(self) -> None:
+ """Cancel the reconnect task."""
+ if self._reconnect_task:
+ self._reconnect_task.cancel()
+ self._reconnect_task = None
+
+ async def _reconnect_loop(self) -> None:
+ """Reconnect to the MQTT server."""
+ while True:
+ if not self.connected:
+ try:
+ async with self._connection_lock, self._async_connect_in_executor():
+ await self.hass.async_add_executor_job(self._mqttc.reconnect)
+ except OSError as err:
+ _LOGGER.debug(
+ "Error re-connecting to MQTT server due to exception: %s", err
+ )
+
+ await asyncio.sleep(RECONNECT_INTERVAL_SECONDS)
async def async_disconnect(self) -> None:
"""Stop the MQTT client."""
- def stop() -> None:
- """Stop the MQTT client."""
- # Do not disconnect, we want the broker to always publish will
- self._mqttc.loop_stop()
-
def no_more_acks() -> bool:
"""Return False if there are unprocessed ACKs."""
return not any(not op.is_set() for op in self._pending_operations.values())
@@ -549,8 +707,10 @@ class MQTT:
await self._pending_operations_condition.wait_for(no_more_acks)
# stop the MQTT loop
- async with self._paho_lock:
- await self.hass.async_add_executor_job(stop)
+ async with self._connection_lock:
+ self._should_reconnect = False
+ self._async_cancel_reconnect()
+ self._mqttc.disconnect()
@callback
def async_restore_tracked_subscriptions(
@@ -689,11 +849,8 @@ class MQTT:
subscriptions: dict[str, int] = self._pending_subscriptions
self._pending_subscriptions = {}
- async with self._paho_lock:
- subscription_list = list(subscriptions.items())
- result, mid = await self.hass.async_add_executor_job(
- self._mqttc.subscribe, subscription_list
- )
+ subscription_list = list(subscriptions.items())
+ result, mid = self._mqttc.subscribe(subscription_list)
for topic, qos in subscriptions.items():
_LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos)
@@ -712,17 +869,15 @@ class MQTT:
topics = list(self._pending_unsubscribes)
self._pending_unsubscribes = set()
- async with self._paho_lock:
- result, mid = await self.hass.async_add_executor_job(
- self._mqttc.unsubscribe, topics
- )
+ result, mid = self._mqttc.unsubscribe(topics)
_raise_on_error(result)
for topic in topics:
_LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid)
await self._wait_for_mid(mid)
- def _mqtt_on_connect(
+ @callback
+ def _async_mqtt_on_connect(
self,
_mqttc: mqtt.Client,
_userdata: None,
@@ -739,14 +894,22 @@ class MQTT:
import paho.mqtt.client as mqtt
if result_code != mqtt.CONNACK_ACCEPTED:
+ if result_code in (
+ mqtt.CONNACK_REFUSED_BAD_USERNAME_PASSWORD,
+ mqtt.CONNACK_REFUSED_NOT_AUTHORIZED,
+ ):
+ self._should_reconnect = False
+ self.hass.async_create_task(self.async_disconnect())
+ self.config_entry.async_start_reauth(self.hass)
_LOGGER.error(
"Unable to connect to the MQTT broker: %s",
mqtt.connack_string(result_code),
)
+ self._async_connection_result(False)
return
self.connected = True
- dispatcher_send(self.hass, MQTT_CONNECTED)
+ async_dispatcher_send(self.hass, MQTT_CONNECTED)
_LOGGER.info(
"Connected to MQTT server %s:%s (%s)",
self.conf[CONF_BROKER],
@@ -754,7 +917,7 @@ class MQTT:
result_code,
)
- self.hass.create_task(self._async_resubscribe())
+ self.hass.async_create_task(self._async_resubscribe())
if birth := self.conf.get(CONF_BIRTH_MESSAGE, DEFAULT_BIRTH):
@@ -771,13 +934,17 @@ class MQTT:
)
birth_message = PublishMessage(**birth)
- asyncio.run_coroutine_threadsafe(
- publish_birth_message(birth_message), self.hass.loop
+ self.config_entry.async_create_background_task(
+ self.hass,
+ publish_birth_message(birth_message),
+ name="mqtt birth message",
)
else:
# Update subscribe cooldown period to a shorter time
self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN)
+ self._async_connection_result(True)
+
async def _async_resubscribe(self) -> None:
"""Resubscribe on reconnect."""
self._max_qos.clear()
@@ -796,16 +963,6 @@ class MQTT:
)
await self._async_perform_subscriptions()
- def _mqtt_on_message(
- self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage
- ) -> None:
- """Message received callback."""
- # MQTT messages tend to be high volume,
- # and since they come in via a thread and need to be processed in the event loop,
- # we want to avoid hass.add_job since most of the time is spent calling
- # inspect to figure out how to run the callback.
- self.loop.call_soon_threadsafe(self._mqtt_handle_message, msg)
-
@lru_cache(None) # pylint: disable=method-cache-max-size-none
def _matching_subscriptions(self, topic: str) -> list[Subscription]:
subscriptions: list[Subscription] = []
@@ -819,7 +976,9 @@ class MQTT:
return subscriptions
@callback
- def _mqtt_handle_message(self, msg: mqtt.MQTTMessage) -> None:
+ def _async_mqtt_on_message(
+ self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage
+ ) -> None:
topic = msg.topic
# msg.topic is a property that decodes the topic to a string
# every time it is accessed. Save the result to avoid
@@ -831,8 +990,6 @@ class MQTT:
msg.qos,
msg.payload[0:8192],
)
- timestamp = dt_util.utcnow()
-
subscriptions = self._matching_subscriptions(topic)
msg_cache_by_subscription_topic: dict[str, ReceiveMessage] = {}
@@ -870,7 +1027,7 @@ class MQTT:
msg.qos,
msg.retain,
subscription_topic,
- timestamp,
+ msg.timestamp,
)
msg_cache_by_subscription_topic[subscription_topic] = receive_msg
else:
@@ -878,7 +1035,8 @@ class MQTT:
self.hass.async_run_hass_job(subscription.job, receive_msg)
self._mqtt_data.state_write_requests.process_write_state_requests(msg)
- def _mqtt_on_callback(
+ @callback
+ def _async_mqtt_on_callback(
self,
_mqttc: mqtt.Client,
_userdata: None,
@@ -890,7 +1048,7 @@ class MQTT:
# The callback signature for on_unsubscribe is different from on_subscribe
# see https://github.com/eclipse/paho.mqtt.python/issues/687
# properties and reasoncodes are not used in Home Assistant
- self.hass.create_task(self._mqtt_handle_mid(mid))
+ self.hass.async_create_task(self._mqtt_handle_mid(mid))
async def _mqtt_handle_mid(self, mid: int) -> None:
# Create the mid event if not created, either _mqtt_handle_mid or _wait_for_mid
@@ -906,7 +1064,8 @@ class MQTT:
if mid not in self._pending_operations:
self._pending_operations[mid] = asyncio.Event()
- def _mqtt_on_disconnect(
+ @callback
+ def _async_mqtt_on_disconnect(
self,
_mqttc: mqtt.Client,
_userdata: None,
@@ -914,8 +1073,19 @@ class MQTT:
properties: mqtt.Properties | None = None,
) -> None:
"""Disconnected callback."""
+ self._async_on_disconnect(result_code)
+
+ @callback
+ def _async_on_disconnect(self, result_code: int) -> None:
+ if not self.connected:
+ # This function is re-entrant and may be called multiple times
+ # when there is a broken pipe error.
+ return
+ # If disconnect is called before the connect
+ # result is set make sure the first connection result is set
+ self._async_connection_result(False)
self.connected = False
- dispatcher_send(self.hass, MQTT_DISCONNECTED)
+ async_dispatcher_send(self.hass, MQTT_DISCONNECTED)
_LOGGER.warning(
"Disconnected from MQTT server %s:%s (%s)",
self.conf[CONF_BROKER],
diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py
index 5bf0c9c1879..1a7dfbbc507 100644
--- a/homeassistant/components/mqtt/config_flow.py
+++ b/homeassistant/components/mqtt/config_flow.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections import OrderedDict
-from collections.abc import Callable
+from collections.abc import Callable, Mapping
import queue
from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError
from types import MappingProxyType
@@ -158,13 +158,46 @@ CERT_UPLOAD_SELECTOR = FileSelector(
)
KEY_UPLOAD_SELECTOR = FileSelector(FileSelectorConfig(accept=".key,application/pkcs8"))
+REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): TEXT_SELECTOR,
+ vol.Required(CONF_PASSWORD): PASSWORD_SELECTOR,
+ }
+)
+PWD_NOT_CHANGED = "__**password_not_changed**__"
+
+
+@callback
+def update_password_from_user_input(
+ entry_password: str | None, user_input: dict[str, Any]
+) -> dict[str, Any]:
+ """Update the password if the entry has been updated.
+
+ As we want to avoid reflecting the stored password in the UI,
+ we replace the suggested value in the UI with a sentitel,
+ and we change it back here if it was changed.
+ """
+ substituted_used_data = dict(user_input)
+ # Take out the password submitted
+ user_password: str | None = substituted_used_data.pop(CONF_PASSWORD, None)
+ # Only add the password if it has changed.
+ # If the sentinel password is submitted, we replace that with our current
+ # password from the config entry data.
+ password_changed = user_password is not None and user_password != PWD_NOT_CHANGED
+ password = user_password if password_changed else entry_password
+ if password is not None:
+ substituted_used_data[CONF_PASSWORD] = password
+ return substituted_used_data
+
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
VERSION = 1
+ entry: ConfigEntry | None
_hassio_discovery: dict[str, Any] | None = None
+ _reauth_config_entry: ConfigEntry | None = None
@staticmethod
@callback
@@ -183,6 +216,49 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_broker()
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle re-authentication with Aladdin Connect."""
+
+ self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm re-authentication with MQTT broker."""
+ errors: dict[str, str] = {}
+
+ assert self.entry is not None
+ if user_input:
+ substituted_used_data = update_password_from_user_input(
+ self.entry.data.get(CONF_PASSWORD), user_input
+ )
+ new_entry_data = {**self.entry.data, **substituted_used_data}
+ if await self.hass.async_add_executor_job(
+ try_connection,
+ new_entry_data,
+ ):
+ return self.async_update_reload_and_abort(
+ self.entry, data=new_entry_data
+ )
+
+ errors["base"] = "invalid_auth"
+
+ schema = self.add_suggested_values_to_schema(
+ REAUTH_SCHEMA,
+ {
+ CONF_USERNAME: self.entry.data.get(CONF_USERNAME),
+ CONF_PASSWORD: PWD_NOT_CHANGED,
+ },
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=schema,
+ errors=errors,
+ )
+
async def async_step_broker(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -291,13 +367,17 @@ class MQTTOptionsFlowHandler(OptionsFlow):
validated_user_input,
errors,
):
+ self.broker_config.update(
+ update_password_from_user_input(
+ self.config_entry.data.get(CONF_PASSWORD), validated_user_input
+ ),
+ )
can_connect = await self.hass.async_add_executor_job(
try_connection,
- validated_user_input,
+ self.broker_config,
)
if can_connect:
- self.broker_config.update(validated_user_input)
return await self.async_step_options()
errors["base"] = "cannot_connect"
@@ -598,7 +678,9 @@ async def async_get_broker_settings(
current_broker = current_config.get(CONF_BROKER)
current_port = current_config.get(CONF_PORT, DEFAULT_PORT)
current_user = current_config.get(CONF_USERNAME)
- current_pass = current_config.get(CONF_PASSWORD)
+ # Return the sentinel password to avoid exposure
+ current_entry_pass = current_config.get(CONF_PASSWORD)
+ current_pass = PWD_NOT_CHANGED if current_entry_pass else None
# Treat the previous post as an update of the current settings
# (if there was a basic broker setup step)
diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py
index 7ff93a6bd06..e84dedde785 100644
--- a/homeassistant/components/mqtt/debug_info.py
+++ b/homeassistant/components/mqtt/debug_info.py
@@ -7,6 +7,7 @@ from collections.abc import Callable
from dataclasses import dataclass
import datetime as dt
from functools import wraps
+import time
from typing import TYPE_CHECKING, Any
from homeassistant.core import HomeAssistant
@@ -57,7 +58,7 @@ class TimestampedPublishMessage:
payload: PublishPayloadType
qos: int
retain: bool
- timestamp: dt.datetime
+ timestamp: float
def log_message(
@@ -77,7 +78,7 @@ def log_message(
"messages": deque([], STORED_MESSAGES),
}
msg = TimestampedPublishMessage(
- topic, payload, qos, retain, timestamp=dt_util.utcnow()
+ topic, payload, qos, retain, timestamp=time.monotonic()
)
entity_info["transmitted"][topic]["messages"].append(msg)
@@ -175,6 +176,7 @@ def remove_trigger_discovery_data(
def _info_for_entity(hass: HomeAssistant, entity_id: str) -> dict[str, Any]:
entity_info = get_mqtt_data(hass).debug_info_entities[entity_id]
+ monotonic_time_diff = time.time() - time.monotonic()
subscriptions = [
{
"topic": topic,
@@ -183,7 +185,10 @@ def _info_for_entity(hass: HomeAssistant, entity_id: str) -> dict[str, Any]:
"payload": str(msg.payload),
"qos": msg.qos,
"retain": msg.retain,
- "time": msg.timestamp,
+ "time": dt_util.utc_from_timestamp(
+ msg.timestamp + monotonic_time_diff,
+ tz=dt.UTC,
+ ),
"topic": msg.topic,
}
for msg in subscription["messages"]
@@ -199,7 +204,10 @@ def _info_for_entity(hass: HomeAssistant, entity_id: str) -> dict[str, Any]:
"payload": str(msg.payload),
"qos": msg.qos,
"retain": msg.retain,
- "time": msg.timestamp,
+ "time": dt_util.utc_from_timestamp(
+ msg.timestamp + monotonic_time_diff,
+ tz=dt.UTC,
+ ),
"topic": msg.topic,
}
for msg in subscription["messages"]
diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json
index 3a284c6719c..34370c82507 100644
--- a/homeassistant/components/mqtt/manifest.json
+++ b/homeassistant/components/mqtt/manifest.json
@@ -1,11 +1,11 @@
{
"domain": "mqtt",
"name": "MQTT",
- "codeowners": ["@emontnemery", "@jbouwh"],
+ "codeowners": ["@emontnemery", "@jbouwh", "@bdraco"],
"config_flow": true,
"dependencies": ["file_upload", "http"],
"documentation": "https://www.home-assistant.io/integrations/mqtt",
"iot_class": "local_push",
- "quality_scale": "gold",
+ "quality_scale": "platinum",
"requirements": ["paho-mqtt==1.6.1"]
}
diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py
index f53643268e7..17640c3e733 100644
--- a/homeassistant/components/mqtt/models.py
+++ b/homeassistant/components/mqtt/models.py
@@ -7,7 +7,6 @@ import asyncio
from collections import deque
from collections.abc import Callable, Coroutine
from dataclasses import dataclass, field
-import datetime as dt
from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any, TypedDict
@@ -67,7 +66,7 @@ class ReceiveMessage:
qos: int
retain: bool
subscribed_topic: str
- timestamp: dt.datetime
+ timestamp: float
AsyncMessageCallbackType = Callable[[ReceiveMessage], Coroutine[Any, Any, None]]
diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json
index 2bd47db63bc..fc5f0bc4970 100644
--- a/homeassistant/components/mqtt/strings.json
+++ b/homeassistant/components/mqtt/strings.json
@@ -68,10 +68,23 @@
"data_description": {
"discovery": "Option to enable MQTT automatic discovery."
}
+ },
+ "reauth_confirm": {
+ "title": "Re-authentication required with the MQTT broker",
+ "description": "The MQTT broker reported an authentication error. Please confirm the brokers correct usernname and password.",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::mqtt::config::step::broker::data_description::username%]",
+ "password": "[%key:component::mqtt::config::step::broker::data_description::password%]"
+ }
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {
@@ -84,6 +97,7 @@
"bad_client_cert_key": "Client certificate and private key are not a valid pair",
"bad_ws_headers": "Supply valid HTTP headers as a JSON object",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_inclusion": "The client certificate and private key must be configurered together"
}
},
diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json
index a4ef9af9aee..7b1c584c293 100644
--- a/homeassistant/components/nam/manifest.json
+++ b/homeassistant/components/nam/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["nettigo_air_monitor"],
"quality_scale": "platinum",
- "requirements": ["nettigo-air-monitor==2.2.2"],
+ "requirements": ["nettigo-air-monitor==3.0.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py
index f269e3e89d6..33793fe836b 100644
--- a/homeassistant/components/nest/device_info.py
+++ b/homeassistant/components/nest/device_info.py
@@ -73,7 +73,7 @@ class NestDeviceInfo:
"""Return device suggested area based on the Google Home room."""
if parent_relations := self._device.parent_relations:
items = sorted(parent_relations.items())
- names = [name for id, name in items]
+ names = [name for _, name in items]
return " ".join(names)
return None
diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py
index d48006c449d..6c481806e4f 100644
--- a/homeassistant/components/nest/media_source.py
+++ b/homeassistant/components/nest/media_source.py
@@ -322,7 +322,7 @@ class NestMediaSource(MediaSource):
devices = async_get_media_source_devices(self.hass)
if not (device := devices.get(media_id.device_id)):
raise Unresolvable(
- "Unable to find device with identifier: %s" % item.identifier
+ f"Unable to find device with identifier: {item.identifier}"
)
if not media_id.event_token:
# The device resolves to the most recent event if available
@@ -330,7 +330,7 @@ class NestMediaSource(MediaSource):
last_event_id := await _async_get_recent_event_id(media_id, device)
):
raise Unresolvable(
- "Unable to resolve recent event for device: %s" % item.identifier
+ f"Unable to resolve recent event for device: {item.identifier}"
)
media_id = last_event_id
@@ -377,7 +377,7 @@ class NestMediaSource(MediaSource):
# Browse either a device or events within a device
if not (device := devices.get(media_id.device_id)):
raise BrowseError(
- "Unable to find device with identiifer: %s" % item.identifier
+ f"Unable to find device with identiifer: {item.identifier}"
)
# Clip previews are a session with multiple possible event types (e.g.
# person, motion, etc) and a single mp4
@@ -399,7 +399,7 @@ class NestMediaSource(MediaSource):
# Browse a specific event
if not (single_clip := clips.get(media_id.event_token)):
raise BrowseError(
- "Unable to find event with identiifer: %s" % item.identifier
+ f"Unable to find event with identiifer: {item.identifier}"
)
return _browse_clip_preview(media_id, device, single_clip)
@@ -419,7 +419,7 @@ class NestMediaSource(MediaSource):
# Browse a specific event
if not (single_image := images.get(media_id.event_token)):
raise BrowseError(
- "Unable to find event with identiifer: %s" % item.identifier
+ f"Unable to find event with identiifer: {item.identifier}"
)
return _browse_image_event(media_id, device, single_image)
diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py
index 0f0c85c1720..4cc77e44ec4 100644
--- a/homeassistant/components/netio/switch.py
+++ b/homeassistant/components/netio/switch.py
@@ -165,7 +165,7 @@ class NetioSwitch(SwitchEntity):
def _set(self, value):
val = list("uuuu")
val[int(self.outlet) - 1] = "1" if value else "0"
- self.netio.get("port list %s" % "".join(val))
+ self.netio.get("port list {}".format("".join(val)))
self.netio.states[int(self.outlet) - 1] = value
self.schedule_update_ha_state()
diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py
index 389173a2694..c7e4a0842fb 100644
--- a/homeassistant/components/nextdns/__init__.py
+++ b/homeassistant/components/nextdns/__init__.py
@@ -4,31 +4,15 @@ from __future__ import annotations
import asyncio
from datetime import timedelta
-import logging
-from typing import TypeVar
from aiohttp.client_exceptions import ClientConnectorError
-from nextdns import (
- AnalyticsDnssec,
- AnalyticsEncryption,
- AnalyticsIpVersions,
- AnalyticsProtocols,
- AnalyticsStatus,
- ApiError,
- ConnectionStatus,
- InvalidApiKeyError,
- NextDns,
- Settings,
-)
-from nextdns.model import NextDnsData
+from nextdns import ApiError, NextDns
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ATTR_CONNECTION,
@@ -44,104 +28,16 @@ from .const import (
UPDATE_INTERVAL_CONNECTION,
UPDATE_INTERVAL_SETTINGS,
)
-
-CoordinatorDataT = TypeVar("CoordinatorDataT", bound=NextDnsData)
-
-
-class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): # pylint: disable=hass-enforce-coordinator-module
- """Class to manage fetching NextDNS data API."""
-
- def __init__(
- self,
- hass: HomeAssistant,
- nextdns: NextDns,
- profile_id: str,
- update_interval: timedelta,
- ) -> None:
- """Initialize."""
- self.nextdns = nextdns
- self.profile_id = profile_id
- self.profile_name = nextdns.get_profile_name(profile_id)
- self.device_info = DeviceInfo(
- configuration_url=f"https://my.nextdns.io/{profile_id}/setup",
- entry_type=DeviceEntryType.SERVICE,
- identifiers={(DOMAIN, str(profile_id))},
- manufacturer="NextDNS Inc.",
- name=self.profile_name,
- )
-
- super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
-
- async def _async_update_data(self) -> CoordinatorDataT:
- """Update data via internal method."""
- try:
- async with asyncio.timeout(10):
- return await self._async_update_data_internal()
- except (ApiError, ClientConnectorError, InvalidApiKeyError) as err:
- raise UpdateFailed(err) from err
-
- async def _async_update_data_internal(self) -> CoordinatorDataT:
- """Update data via library."""
- raise NotImplementedError("Update method not implemented")
-
-
-class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): # pylint: disable=hass-enforce-coordinator-module
- """Class to manage fetching NextDNS analytics status data from API."""
-
- async def _async_update_data_internal(self) -> AnalyticsStatus:
- """Update data via library."""
- return await self.nextdns.get_analytics_status(self.profile_id)
-
-
-class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): # pylint: disable=hass-enforce-coordinator-module
- """Class to manage fetching NextDNS analytics Dnssec data from API."""
-
- async def _async_update_data_internal(self) -> AnalyticsDnssec:
- """Update data via library."""
- return await self.nextdns.get_analytics_dnssec(self.profile_id)
-
-
-class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncryption]): # pylint: disable=hass-enforce-coordinator-module
- """Class to manage fetching NextDNS analytics encryption data from API."""
-
- async def _async_update_data_internal(self) -> AnalyticsEncryption:
- """Update data via library."""
- return await self.nextdns.get_analytics_encryption(self.profile_id)
-
-
-class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVersions]): # pylint: disable=hass-enforce-coordinator-module
- """Class to manage fetching NextDNS analytics IP versions data from API."""
-
- async def _async_update_data_internal(self) -> AnalyticsIpVersions:
- """Update data via library."""
- return await self.nextdns.get_analytics_ip_versions(self.profile_id)
-
-
-class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtocols]): # pylint: disable=hass-enforce-coordinator-module
- """Class to manage fetching NextDNS analytics protocols data from API."""
-
- async def _async_update_data_internal(self) -> AnalyticsProtocols:
- """Update data via library."""
- return await self.nextdns.get_analytics_protocols(self.profile_id)
-
-
-class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): # pylint: disable=hass-enforce-coordinator-module
- """Class to manage fetching NextDNS connection data from API."""
-
- async def _async_update_data_internal(self) -> Settings:
- """Update data via library."""
- return await self.nextdns.get_settings(self.profile_id)
-
-
-class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStatus]): # pylint: disable=hass-enforce-coordinator-module
- """Class to manage fetching NextDNS connection data from API."""
-
- async def _async_update_data_internal(self) -> ConnectionStatus:
- """Update data via library."""
- return await self.nextdns.connection_status(self.profile_id)
-
-
-_LOGGER = logging.getLogger(__name__)
+from .coordinator import (
+ NextDnsConnectionUpdateCoordinator,
+ NextDnsDnssecUpdateCoordinator,
+ NextDnsEncryptionUpdateCoordinator,
+ NextDnsIpVersionsUpdateCoordinator,
+ NextDnsProtocolsUpdateCoordinator,
+ NextDnsSettingsUpdateCoordinator,
+ NextDnsStatusUpdateCoordinator,
+ NextDnsUpdateCoordinator,
+)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator], timedelta]] = [
diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py
index f6860586808..1bb79cf4fce 100644
--- a/homeassistant/components/nextdns/binary_sensor.py
+++ b/homeassistant/components/nextdns/binary_sensor.py
@@ -19,8 +19,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import CoordinatorDataT, NextDnsConnectionUpdateCoordinator
from .const import ATTR_CONNECTION, DOMAIN
+from .coordinator import CoordinatorDataT, NextDnsConnectionUpdateCoordinator
PARALLEL_UPDATES = 1
diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py
index d74152248a5..d61c953f260 100644
--- a/homeassistant/components/nextdns/button.py
+++ b/homeassistant/components/nextdns/button.py
@@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import NextDnsStatusUpdateCoordinator
from .const import ATTR_STATUS, DOMAIN
+from .coordinator import NextDnsStatusUpdateCoordinator
PARALLEL_UPDATES = 1
diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py
new file mode 100644
index 00000000000..cad1aeac070
--- /dev/null
+++ b/homeassistant/components/nextdns/coordinator.py
@@ -0,0 +1,124 @@
+"""NextDns coordinator."""
+
+import asyncio
+from datetime import timedelta
+import logging
+from typing import TypeVar
+
+from aiohttp.client_exceptions import ClientConnectorError
+from nextdns import (
+ AnalyticsDnssec,
+ AnalyticsEncryption,
+ AnalyticsIpVersions,
+ AnalyticsProtocols,
+ AnalyticsStatus,
+ ApiError,
+ ConnectionStatus,
+ InvalidApiKeyError,
+ NextDns,
+ Settings,
+)
+from nextdns.model import NextDnsData
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+CoordinatorDataT = TypeVar("CoordinatorDataT", bound=NextDnsData)
+
+
+class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]):
+ """Class to manage fetching NextDNS data API."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ nextdns: NextDns,
+ profile_id: str,
+ update_interval: timedelta,
+ ) -> None:
+ """Initialize."""
+ self.nextdns = nextdns
+ self.profile_id = profile_id
+ self.profile_name = nextdns.get_profile_name(profile_id)
+ self.device_info = DeviceInfo(
+ configuration_url=f"https://my.nextdns.io/{profile_id}/setup",
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, str(profile_id))},
+ manufacturer="NextDNS Inc.",
+ name=self.profile_name,
+ )
+
+ super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
+
+ async def _async_update_data(self) -> CoordinatorDataT:
+ """Update data via internal method."""
+ try:
+ async with asyncio.timeout(10):
+ return await self._async_update_data_internal()
+ except (ApiError, ClientConnectorError, InvalidApiKeyError) as err:
+ raise UpdateFailed(err) from err
+
+ async def _async_update_data_internal(self) -> CoordinatorDataT:
+ """Update data via library."""
+ raise NotImplementedError("Update method not implemented")
+
+
+class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]):
+ """Class to manage fetching NextDNS analytics status data from API."""
+
+ async def _async_update_data_internal(self) -> AnalyticsStatus:
+ """Update data via library."""
+ return await self.nextdns.get_analytics_status(self.profile_id)
+
+
+class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]):
+ """Class to manage fetching NextDNS analytics Dnssec data from API."""
+
+ async def _async_update_data_internal(self) -> AnalyticsDnssec:
+ """Update data via library."""
+ return await self.nextdns.get_analytics_dnssec(self.profile_id)
+
+
+class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncryption]):
+ """Class to manage fetching NextDNS analytics encryption data from API."""
+
+ async def _async_update_data_internal(self) -> AnalyticsEncryption:
+ """Update data via library."""
+ return await self.nextdns.get_analytics_encryption(self.profile_id)
+
+
+class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVersions]):
+ """Class to manage fetching NextDNS analytics IP versions data from API."""
+
+ async def _async_update_data_internal(self) -> AnalyticsIpVersions:
+ """Update data via library."""
+ return await self.nextdns.get_analytics_ip_versions(self.profile_id)
+
+
+class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtocols]):
+ """Class to manage fetching NextDNS analytics protocols data from API."""
+
+ async def _async_update_data_internal(self) -> AnalyticsProtocols:
+ """Update data via library."""
+ return await self.nextdns.get_analytics_protocols(self.profile_id)
+
+
+class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]):
+ """Class to manage fetching NextDNS connection data from API."""
+
+ async def _async_update_data_internal(self) -> Settings:
+ """Update data via library."""
+ return await self.nextdns.get_settings(self.profile_id)
+
+
+class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStatus]):
+ """Class to manage fetching NextDNS connection data from API."""
+
+ async def _async_update_data_internal(self) -> ConnectionStatus:
+ """Update data via library."""
+ return await self.nextdns.connection_status(self.profile_id)
diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json
index 611021d73e4..1e7145ef6d1 100644
--- a/homeassistant/components/nextdns/manifest.json
+++ b/homeassistant/components/nextdns/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["nextdns"],
"quality_scale": "platinum",
- "requirements": ["nextdns==2.1.0"]
+ "requirements": ["nextdns==3.0.0"]
}
diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py
index 4357179cbdb..3ac2179ed31 100644
--- a/homeassistant/components/nextdns/sensor.py
+++ b/homeassistant/components/nextdns/sensor.py
@@ -26,7 +26,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import CoordinatorDataT, NextDnsUpdateCoordinator
from .const import (
ATTR_DNSSEC,
ATTR_ENCRYPTION,
@@ -35,6 +34,7 @@ from .const import (
ATTR_STATUS,
DOMAIN,
)
+from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator
PARALLEL_UPDATES = 1
diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py
index 81bf8b4e8c6..dfb796efd8c 100644
--- a/homeassistant/components/nextdns/switch.py
+++ b/homeassistant/components/nextdns/switch.py
@@ -18,8 +18,8 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import CoordinatorDataT, NextDnsSettingsUpdateCoordinator
from .const import ATTR_SETTINGS, DOMAIN
+from .coordinator import CoordinatorDataT, NextDnsSettingsUpdateCoordinator
PARALLEL_UPDATES = 1
diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py
index dd42a0ab10b..dd6b15400d9 100644
--- a/homeassistant/components/nfandroidtv/notify.py
+++ b/homeassistant/components/nfandroidtv/notify.py
@@ -19,6 +19,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -44,6 +45,7 @@ from .const import (
ATTR_POSITION,
ATTR_TRANSPARENCY,
DEFAULT_TIMEOUT,
+ DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
@@ -133,21 +135,49 @@ class NFAndroidTVNotificationService(BaseNotificationService):
"Invalid interrupt-value: %s", data.get(ATTR_INTERRUPT)
)
if imagedata := data.get(ATTR_IMAGE):
- image_file = self.load_file(
- url=imagedata.get(ATTR_IMAGE_URL),
- local_path=imagedata.get(ATTR_IMAGE_PATH),
- username=imagedata.get(ATTR_IMAGE_USERNAME),
- password=imagedata.get(ATTR_IMAGE_PASSWORD),
- auth=imagedata.get(ATTR_IMAGE_AUTH),
- )
+ if isinstance(imagedata, str):
+ image_file = (
+ self.load_file(url=imagedata)
+ if imagedata.startswith("http")
+ else self.load_file(local_path=imagedata)
+ )
+ elif isinstance(imagedata, dict):
+ image_file = self.load_file(
+ url=imagedata.get(ATTR_IMAGE_URL),
+ local_path=imagedata.get(ATTR_IMAGE_PATH),
+ username=imagedata.get(ATTR_IMAGE_USERNAME),
+ password=imagedata.get(ATTR_IMAGE_PASSWORD),
+ auth=imagedata.get(ATTR_IMAGE_AUTH),
+ )
+ else:
+ raise ServiceValidationError(
+ "Invalid image provided",
+ translation_domain=DOMAIN,
+ translation_key="invalid_notification_image",
+ translation_placeholders={"type": type(imagedata).__name__},
+ )
if icondata := data.get(ATTR_ICON):
- icon = self.load_file(
- url=icondata.get(ATTR_ICON_URL),
- local_path=icondata.get(ATTR_ICON_PATH),
- username=icondata.get(ATTR_ICON_USERNAME),
- password=icondata.get(ATTR_ICON_PASSWORD),
- auth=icondata.get(ATTR_ICON_AUTH),
- )
+ if isinstance(icondata, str):
+ icondata = (
+ self.load_file(url=icondata)
+ if icondata.startswith("http")
+ else self.load_file(local_path=icondata)
+ )
+ elif isinstance(icondata, dict):
+ icon = self.load_file(
+ url=icondata.get(ATTR_ICON_URL),
+ local_path=icondata.get(ATTR_ICON_PATH),
+ username=icondata.get(ATTR_ICON_USERNAME),
+ password=icondata.get(ATTR_ICON_PASSWORD),
+ auth=icondata.get(ATTR_ICON_AUTH),
+ )
+ else:
+ raise ServiceValidationError(
+ "Invalid Icon provided",
+ translation_domain=DOMAIN,
+ translation_key="invalid_notification_icon",
+ translation_placeholders={"type": type(icondata).__name__},
+ )
self.notify.send(
message,
title=title,
diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json
index cde02327712..e73fc68d66a 100644
--- a/homeassistant/components/nfandroidtv/strings.json
+++ b/homeassistant/components/nfandroidtv/strings.json
@@ -1,4 +1,12 @@
{
+ "exceptions": {
+ "invalid_notification_icon": {
+ "message": "Invalid icon data provided. Got {type}"
+ },
+ "invalid_notification_image": {
+ "message": "Invalid image data provided. Got {type}"
+ }
+ },
"config": {
"step": {
"user": {
diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py
index 853370066dc..e25ae1f0877 100644
--- a/homeassistant/components/ollama/const.py
+++ b/homeassistant/components/ollama/const.py
@@ -81,75 +81,86 @@ DEFAULT_MAX_HISTORY = 20
MAX_HISTORY_SECONDS = 60 * 60 # 1 hour
MODEL_NAMES = [ # https://ollama.com/library
- "gemma",
- "llama2",
- "mistral",
- "mixtral",
- "llava",
- "neural-chat",
- "codellama",
- "dolphin-mixtral",
- "qwen",
- "llama2-uncensored",
- "mistral-openorca",
- "deepseek-coder",
- "nous-hermes2",
- "phi",
- "orca-mini",
- "dolphin-mistral",
- "wizard-vicuna-uncensored",
- "vicuna",
- "tinydolphin",
- "llama2-chinese",
- "nomic-embed-text",
- "openhermes",
- "zephyr",
- "tinyllama",
- "openchat",
- "wizardcoder",
- "starcoder",
- "phind-codellama",
- "starcoder2",
- "yi",
- "orca2",
- "falcon",
- "wizard-math",
- "dolphin-phi",
- "starling-lm",
- "nous-hermes",
- "stable-code",
- "medllama2",
- "bakllava",
- "codeup",
- "wizardlm-uncensored",
- "solar",
- "everythinglm",
- "sqlcoder",
- "dolphincoder",
- "nous-hermes2-mixtral",
- "stable-beluga",
- "yarn-mistral",
- "stablelm2",
- "samantha-mistral",
- "meditron",
- "stablelm-zephyr",
- "magicoder",
- "yarn-llama2",
- "llama-pro",
- "deepseek-llm",
- "wizard-vicuna",
- "codebooga",
- "mistrallite",
- "all-minilm",
- "nexusraven",
- "open-orca-platypus2",
- "goliath",
- "notux",
- "megadolphin",
"alfred",
- "xwinlm",
- "wizardlm",
+ "all-minilm",
+ "bakllava",
+ "codebooga",
+ "codegemma",
+ "codellama",
+ "codeqwen",
+ "codeup",
+ "command-r",
+ "command-r-plus",
+ "dbrx",
+ "deepseek-coder",
+ "deepseek-llm",
+ "dolphin-llama3",
+ "dolphin-mistral",
+ "dolphin-mixtral",
+ "dolphin-phi",
+ "dolphincoder",
"duckdb-nsql",
+ "everythinglm",
+ "falcon",
+ "gemma",
+ "goliath",
+ "llama-pro",
+ "llama2",
+ "llama2-chinese",
+ "llama2-uncensored",
+ "llama3",
+ "llava",
+ "magicoder",
+ "meditron",
+ "medllama2",
+ "megadolphin",
+ "mistral",
+ "mistral-openorca",
+ "mistrallite",
+ "mixtral",
+ "mxbai-embed-large",
+ "neural-chat",
+ "nexusraven",
+ "nomic-embed-text",
"notus",
+ "notux",
+ "nous-hermes",
+ "nous-hermes2",
+ "nous-hermes2-mixtral",
+ "open-orca-platypus2",
+ "openchat",
+ "openhermes",
+ "orca-mini",
+ "orca2",
+ "phi",
+ "phi3",
+ "phind-codellama",
+ "qwen",
+ "samantha-mistral",
+ "snowflake-arctic-embed",
+ "solar",
+ "sqlcoder",
+ "stable-beluga",
+ "stable-code",
+ "stablelm-zephyr",
+ "stablelm2",
+ "starcoder",
+ "starcoder2",
+ "starling-lm",
+ "tinydolphin",
+ "tinyllama",
+ "vicuna",
+ "wizard-math",
+ "wizard-vicuna",
+ "wizard-vicuna-uncensored",
+ "wizardcoder",
+ "wizardlm",
+ "wizardlm-uncensored",
+ "wizardlm2",
+ "xwinlm",
+ "yarn-llama2",
+ "yarn-mistral",
+ "yi",
+ "zephyr",
]
DEFAULT_MODEL = "llama2:latest"
diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py
index fea78fd3760..d2e66609103 100644
--- a/homeassistant/components/onewire/binary_sensor.py
+++ b/homeassistant/components/onewire/binary_sensor.py
@@ -36,33 +36,33 @@ class OneWireBinarySensorEntityDescription(
DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = {
"12": tuple(
OneWireBinarySensorEntityDescription(
- key=f"sensed.{id}",
+ key=f"sensed.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
translation_key="sensed_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_A_B
+ for device_key in DEVICE_KEYS_A_B
),
"29": tuple(
OneWireBinarySensorEntityDescription(
- key=f"sensed.{id}",
+ key=f"sensed.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
translation_key="sensed_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_0_7
+ for device_key in DEVICE_KEYS_0_7
),
"3A": tuple(
OneWireBinarySensorEntityDescription(
- key=f"sensed.{id}",
+ key=f"sensed.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
translation_key="sensed_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_A_B
+ for device_key in DEVICE_KEYS_A_B
),
"EF": (), # "HobbyBoard": special
}
@@ -71,15 +71,15 @@ DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ...
HOBBYBOARD_EF: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = {
"HB_HUB": tuple(
OneWireBinarySensorEntityDescription(
- key=f"hub/short.{id}",
+ key=f"hub/short.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
translation_key="hub_short_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_0_3
+ for device_key in DEVICE_KEYS_0_3
),
}
diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py
index d32afce7fa9..46f18842d51 100644
--- a/homeassistant/components/onewire/sensor.py
+++ b/homeassistant/components/onewire/sensor.py
@@ -233,14 +233,14 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = {
"42": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,),
"1D": tuple(
OneWireSensorEntityDescription(
- key=f"counter.{id}",
+ key=f"counter.{device_key}",
native_unit_of_measurement="count",
read_mode=READ_MODE_INT,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="counter_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_A_B
+ for device_key in DEVICE_KEYS_A_B
),
}
@@ -273,15 +273,15 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = {
),
"HB_MOISTURE_METER": tuple(
OneWireSensorEntityDescription(
- key=f"moisture/sensor.{id}",
+ key=f"moisture/sensor.{device_key}",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.CBAR,
read_mode=READ_MODE_FLOAT,
state_class=SensorStateClass.MEASUREMENT,
translation_key="moisture_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_0_3
+ for device_key in DEVICE_KEYS_0_3
),
}
diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py
index cdf1315394e..41276218540 100644
--- a/homeassistant/components/onewire/switch.py
+++ b/homeassistant/components/onewire/switch.py
@@ -40,23 +40,23 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = {
"12": tuple(
[
OneWireSwitchEntityDescription(
- key=f"PIO.{id}",
+ key=f"PIO.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
translation_key="pio_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_A_B
+ for device_key in DEVICE_KEYS_A_B
]
+ [
OneWireSwitchEntityDescription(
- key=f"latch.{id}",
+ key=f"latch.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
translation_key="latch_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_A_B
+ for device_key in DEVICE_KEYS_A_B
]
),
"26": (
@@ -71,34 +71,34 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = {
"29": tuple(
[
OneWireSwitchEntityDescription(
- key=f"PIO.{id}",
+ key=f"PIO.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
translation_key="pio_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_0_7
+ for device_key in DEVICE_KEYS_0_7
]
+ [
OneWireSwitchEntityDescription(
- key=f"latch.{id}",
+ key=f"latch.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
translation_key="latch_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_0_7
+ for device_key in DEVICE_KEYS_0_7
]
),
"3A": tuple(
OneWireSwitchEntityDescription(
- key=f"PIO.{id}",
+ key=f"PIO.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
translation_key="pio_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_A_B
+ for device_key in DEVICE_KEYS_A_B
),
"EF": (), # "HobbyBoard": special
}
@@ -108,37 +108,37 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = {
HOBBYBOARD_EF: dict[str, tuple[OneWireEntityDescription, ...]] = {
"HB_HUB": tuple(
OneWireSwitchEntityDescription(
- key=f"hub/branch.{id}",
+ key=f"hub/branch.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
entity_category=EntityCategory.CONFIG,
translation_key="hub_branch_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_0_3
+ for device_key in DEVICE_KEYS_0_3
),
"HB_MOISTURE_METER": tuple(
[
OneWireSwitchEntityDescription(
- key=f"moisture/is_leaf.{id}",
+ key=f"moisture/is_leaf.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
entity_category=EntityCategory.CONFIG,
translation_key="leaf_sensor_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_0_3
+ for device_key in DEVICE_KEYS_0_3
]
+ [
OneWireSwitchEntityDescription(
- key=f"moisture/is_moisture.{id}",
+ key=f"moisture/is_moisture.{device_key}",
entity_registry_enabled_default=False,
read_mode=READ_MODE_BOOL,
entity_category=EntityCategory.CONFIG,
translation_key="moisture_sensor_id",
- translation_placeholders={"id": str(id)},
+ translation_placeholders={"id": str(device_key)},
)
- for id in DEVICE_KEYS_0_3
+ for device_key in DEVICE_KEYS_0_3
]
),
}
diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py
index c0503e6e850..7575443c793 100644
--- a/homeassistant/components/onkyo/media_player.py
+++ b/homeassistant/components/onkyo/media_player.py
@@ -442,6 +442,7 @@ class OnkyoDevice(MediaPlayerEntity):
"output_color_schema": _tuple_get(values, 6),
"output_color_depth": _tuple_get(values, 7),
"picture_mode": _tuple_get(values, 8),
+ "dynamic_range": _tuple_get(values, 9),
}
self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = info
else:
diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py
index 48ea01e8bb8..20ff22cea23 100644
--- a/homeassistant/components/osoenergy/__init__.py
+++ b/homeassistant/components/osoenergy/__init__.py
@@ -16,18 +16,25 @@ from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
-_T = TypeVar(
- "_T", OSOEnergyBinarySensorData, OSOEnergySensorData, OSOEnergyWaterHeaterData
+_OSOEnergyT = TypeVar(
+ "_OSOEnergyT",
+ OSOEnergyBinarySensorData,
+ OSOEnergySensorData,
+ OSOEnergyWaterHeaterData,
)
+MANUFACTURER = "OSO Energy"
PLATFORMS = [
+ Platform.SENSOR,
Platform.WATER_HEATER,
]
PLATFORM_LOOKUP = {
+ Platform.SENSOR: "sensor",
Platform.WATER_HEATER: "water_heater",
}
@@ -70,13 +77,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
-class OSOEnergyEntity(Entity, Generic[_T]):
+class OSOEnergyEntity(Entity, Generic[_OSOEnergyT]):
"""Initiate OSO Energy Base Class."""
_attr_has_entity_name = True
- def __init__(self, osoenergy: OSOEnergy, osoenergy_device: _T) -> None:
+ def __init__(self, osoenergy: OSOEnergy, entity_data: _OSOEnergyT) -> None:
"""Initialize the instance."""
self.osoenergy = osoenergy
- self.device = osoenergy_device
- self._attr_unique_id = osoenergy_device.device_id
+ self.entity_data = entity_data
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, entity_data.device_id)},
+ manufacturer=MANUFACTURER,
+ model=entity_data.device_type,
+ name=entity_data.device_name,
+ )
diff --git a/homeassistant/components/osoenergy/sensor.py b/homeassistant/components/osoenergy/sensor.py
new file mode 100644
index 00000000000..0be6ad83281
--- /dev/null
+++ b/homeassistant/components/osoenergy/sensor.py
@@ -0,0 +1,151 @@
+"""Support for OSO Energy sensors."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from apyosoenergyapi import OSOEnergy
+from apyosoenergyapi.helper.const import OSOEnergySensorData
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from . import OSOEnergyEntity
+from .const import DOMAIN
+
+
+@dataclass(frozen=True, kw_only=True)
+class OSOEnergySensorEntityDescription(SensorEntityDescription):
+ """Class describing OSO Energy heater sensor entities."""
+
+ value_fn: Callable[[OSOEnergy], StateType]
+
+
+SENSOR_TYPES: dict[str, OSOEnergySensorEntityDescription] = {
+ "heater_mode": OSOEnergySensorEntityDescription(
+ key="heater_mode",
+ translation_key="heater_mode",
+ device_class=SensorDeviceClass.ENUM,
+ options=[
+ "auto",
+ "manual",
+ "off",
+ "legionella",
+ "powersave",
+ "extraenergy",
+ "voltage",
+ "ffr",
+ ],
+ value_fn=lambda entity_data: entity_data.state.lower(),
+ ),
+ "optimization_mode": OSOEnergySensorEntityDescription(
+ key="optimization_mode",
+ translation_key="optimization_mode",
+ device_class=SensorDeviceClass.ENUM,
+ options=["off", "oso", "gridcompany", "smartcompany", "advanced"],
+ value_fn=lambda entity_data: entity_data.state.lower(),
+ ),
+ "power_load": OSOEnergySensorEntityDescription(
+ key="power_load",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.KILO_WATT,
+ value_fn=lambda entity_data: entity_data.state,
+ ),
+ "tapping_capacity": OSOEnergySensorEntityDescription(
+ key="tapping_capacity",
+ translation_key="tapping_capacity",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_fn=lambda entity_data: entity_data.state,
+ ),
+ "capacity_mixed_water_40": OSOEnergySensorEntityDescription(
+ key="capacity_mixed_water_40",
+ translation_key="capacity_mixed_water_40",
+ device_class=SensorDeviceClass.VOLUME,
+ native_unit_of_measurement=UnitOfVolume.LITERS,
+ value_fn=lambda entity_data: entity_data.state,
+ ),
+ "v40_min": OSOEnergySensorEntityDescription(
+ key="v40_min",
+ translation_key="v40_min",
+ device_class=SensorDeviceClass.VOLUME,
+ native_unit_of_measurement=UnitOfVolume.LITERS,
+ value_fn=lambda entity_data: entity_data.state,
+ ),
+ "v40_level_min": OSOEnergySensorEntityDescription(
+ key="v40_level_min",
+ translation_key="v40_level_min",
+ device_class=SensorDeviceClass.VOLUME,
+ native_unit_of_measurement=UnitOfVolume.LITERS,
+ value_fn=lambda entity_data: entity_data.state,
+ ),
+ "v40_level_max": OSOEnergySensorEntityDescription(
+ key="v40_level_max",
+ translation_key="v40_level_max",
+ device_class=SensorDeviceClass.VOLUME,
+ native_unit_of_measurement=UnitOfVolume.LITERS,
+ value_fn=lambda entity_data: entity_data.state,
+ ),
+ "volume": OSOEnergySensorEntityDescription(
+ key="volume",
+ device_class=SensorDeviceClass.VOLUME,
+ native_unit_of_measurement=UnitOfVolume.LITERS,
+ value_fn=lambda entity_data: entity_data.state,
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up OSO Energy sensor."""
+ osoenergy = hass.data[DOMAIN][entry.entry_id]
+ devices = osoenergy.session.device_list.get("sensor")
+ entities = []
+ if devices:
+ for dev in devices:
+ sensor_type = dev.osoEnergyType.lower()
+ if sensor_type in SENSOR_TYPES:
+ entities.append(
+ OSOEnergySensor(osoenergy, SENSOR_TYPES[sensor_type], dev)
+ )
+
+ async_add_entities(entities, True)
+
+
+class OSOEnergySensor(OSOEnergyEntity[OSOEnergySensorData], SensorEntity):
+ """OSO Energy Sensor Entity."""
+
+ entity_description: OSOEnergySensorEntityDescription
+
+ def __init__(
+ self,
+ instance: OSOEnergy,
+ description: OSOEnergySensorEntityDescription,
+ entity_data: OSOEnergySensorData,
+ ) -> None:
+ """Initialize the OSO Energy sensor."""
+ super().__init__(instance, entity_data)
+
+ device_id = entity_data.device_id
+ self._attr_unique_id = f"{device_id}_{description.key}"
+ self.entity_description = description
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.entity_data)
+
+ async def async_update(self) -> None:
+ """Update all data for OSO Energy."""
+ await self.osoenergy.session.update_data()
+ self.entity_data = await self.osoenergy.sensor.get_sensor(self.entity_data)
diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json
index a45482bf030..5313f1d6565 100644
--- a/homeassistant/components/osoenergy/strings.json
+++ b/homeassistant/components/osoenergy/strings.json
@@ -17,13 +17,56 @@
}
},
"error": {
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "unknown": "[%key:common::config_flow::error::unknown%]"
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
+ },
+ "entity": {
+ "sensor": {
+ "tapping_capacity": {
+ "name": "Tapping capacity"
+ },
+ "capacity_mixed_water_40": {
+ "name": "Capacity mixed water 40°C"
+ },
+ "v40_min": {
+ "name": "Mixed water at 40°C"
+ },
+ "v40_level_min": {
+ "name": "Minimum level of mixed water at 40°C"
+ },
+ "v40_level_max": {
+ "name": "Maximum level of mixed water at 40°C"
+ },
+ "heater_mode": {
+ "name": "Heater mode",
+ "state": {
+ "auto": "Auto",
+ "extraenergy": "Extra energy",
+ "ffr": "Fast frequency reserve",
+ "legionella": "Legionella",
+ "manual": "Manual",
+ "off": "Off",
+ "powersave": "Power save",
+ "voltage": "Voltage"
+ }
+ },
+ "optimization_mode": {
+ "name": "Optimization mode",
+ "state": {
+ "advanced": "Advanced",
+ "gridcompany": "Grid company",
+ "off": "Off",
+ "oso": "OSO",
+ "smartcompany": "Smart company"
+ }
+ },
+ "profile": {
+ "name": "Profile local"
+ }
+ }
}
}
diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py
index eaf54a9f9a4..b7fb2ba16e6 100644
--- a/homeassistant/components/osoenergy/water_heater.py
+++ b/homeassistant/components/osoenergy/water_heater.py
@@ -2,6 +2,7 @@
from typing import Any
+from apyosoenergyapi import OSOEnergy
from apyosoenergyapi.helper.const import OSOEnergyWaterHeaterData
from homeassistant.components.water_heater import (
@@ -15,7 +16,6 @@ from homeassistant.components.water_heater import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OSOEnergyEntity
@@ -34,9 +34,6 @@ CURRENT_OPERATION_MAP: dict[str, Any] = {
"extraenergy": STATE_HIGH_DEMAND,
},
}
-HEATER_MIN_TEMP = 10
-HEATER_MAX_TEMP = 80
-MANUFACTURER = "OSO Energy"
async def async_setup_entry(
@@ -59,30 +56,29 @@ class OSOEnergyWaterHeater(
_attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- @property
- def device_info(self) -> DeviceInfo:
- """Return device information."""
- return DeviceInfo(
- identifiers={(DOMAIN, self.device.device_id)},
- manufacturer=MANUFACTURER,
- model=self.device.device_type,
- name=self.device.device_name,
- )
+ def __init__(
+ self,
+ instance: OSOEnergy,
+ entity_data: OSOEnergyWaterHeaterData,
+ ) -> None:
+ """Initialize the OSO Energy water heater."""
+ super().__init__(instance, entity_data)
+ self._attr_unique_id = entity_data.device_id
@property
def available(self) -> bool:
"""Return if the device is available."""
- return self.device.available
+ return self.entity_data.available
@property
def current_operation(self) -> str:
"""Return current operation."""
- status = self.device.current_operation
+ status = self.entity_data.current_operation
if status == "off":
return STATE_OFF
- optimization_mode = self.device.optimization_mode.lower()
- heater_mode = self.device.heater_mode.lower()
+ optimization_mode = self.entity_data.optimization_mode.lower()
+ heater_mode = self.entity_data.heater_mode.lower()
if optimization_mode in CURRENT_OPERATION_MAP:
return CURRENT_OPERATION_MAP[optimization_mode].get(
heater_mode, STATE_ELECTRIC
@@ -93,49 +89,51 @@ class OSOEnergyWaterHeater(
@property
def current_temperature(self) -> float:
"""Return the current temperature of the heater."""
- return self.device.current_temperature
+ return self.entity_data.current_temperature
@property
def target_temperature(self) -> float:
"""Return the temperature we try to reach."""
- return self.device.target_temperature
+ return self.entity_data.target_temperature
@property
def target_temperature_high(self) -> float:
"""Return the temperature we try to reach."""
- return self.device.target_temperature_high
+ return self.entity_data.target_temperature_high
@property
def target_temperature_low(self) -> float:
"""Return the temperature we try to reach."""
- return self.device.target_temperature_low
+ return self.entity_data.target_temperature_low
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
- return self.device.min_temperature
+ return self.entity_data.min_temperature
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
- return self.device.max_temperature
+ return self.entity_data.max_temperature
async def async_turn_on(self, **kwargs) -> None:
"""Turn on hotwater."""
- await self.osoenergy.hotwater.turn_on(self.device, True)
+ await self.osoenergy.hotwater.turn_on(self.entity_data, True)
async def async_turn_off(self, **kwargs) -> None:
"""Turn off hotwater."""
- await self.osoenergy.hotwater.turn_off(self.device, True)
+ await self.osoenergy.hotwater.turn_off(self.entity_data, True)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
target_temperature = int(kwargs.get("temperature", self.target_temperature))
profile = [target_temperature] * 24
- await self.osoenergy.hotwater.set_profile(self.device, profile)
+ await self.osoenergy.hotwater.set_profile(self.entity_data, profile)
async def async_update(self) -> None:
"""Update all Node data from Hive."""
await self.osoenergy.session.update_data()
- self.device = await self.osoenergy.hotwater.get_water_heater(self.device)
+ self.entity_data = await self.osoenergy.hotwater.get_water_heater(
+ self.entity_data
+ )
diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py
index e0c2b77664a..d207f3161f4 100644
--- a/homeassistant/components/ovo_energy/__init__.py
+++ b/homeassistant/components/ovo_energy/__init__.py
@@ -7,13 +7,14 @@ from datetime import timedelta
import logging
import aiohttp
+from ovoenergy import OVOEnergy
from ovoenergy.models import OVODailyUsage
-from ovoenergy.ovoenergy import OVOEnergy
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.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
@@ -32,29 +33,35 @@ PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up OVO Energy from a config entry."""
- client = OVOEnergy()
+ client = OVOEnergy(
+ client_session=async_get_clientsession(hass),
+ )
+
+ if custom_account := entry.data.get(CONF_ACCOUNT) is not None:
+ client.custom_account_id = custom_account
try:
- authenticated = await client.authenticate(
+ if not await client.authenticate(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
- entry.data[CONF_ACCOUNT],
- )
+ ):
+ raise ConfigEntryAuthFailed
+
+ await client.bootstrap_accounts()
except aiohttp.ClientError as exception:
_LOGGER.warning(exception)
raise ConfigEntryNotReady from exception
- if not authenticated:
- raise ConfigEntryAuthFailed
-
async def async_update_data() -> OVODailyUsage:
"""Fetch data from OVO Energy."""
+ if custom_account := entry.data.get(CONF_ACCOUNT) is not None:
+ client.custom_account_id = custom_account
+
async with asyncio.timeout(10):
try:
authenticated = await client.authenticate(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
- entry.data[CONF_ACCOUNT],
)
except aiohttp.ClientError as exception:
raise UpdateFailed(exception) from exception
diff --git a/homeassistant/components/ovo_energy/config_flow.py b/homeassistant/components/ovo_energy/config_flow.py
index 41c64913764..87d53e5fbf9 100644
--- a/homeassistant/components/ovo_energy/config_flow.py
+++ b/homeassistant/components/ovo_energy/config_flow.py
@@ -6,11 +6,12 @@ from collections.abc import Mapping
from typing import Any
import aiohttp
-from ovoenergy.ovoenergy import OVOEnergy
+from ovoenergy import OVOEnergy
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_ACCOUNT, DOMAIN
@@ -41,13 +42,19 @@ class OVOEnergyFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initiated by the user."""
errors = {}
if user_input is not None:
- client = OVOEnergy()
+ client = OVOEnergy(
+ client_session=async_get_clientsession(self.hass),
+ )
+
+ if custom_account := user_input.get(CONF_ACCOUNT) is not None:
+ client.custom_account_id = custom_account
+
try:
authenticated = await client.authenticate(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
- user_input.get(CONF_ACCOUNT, None),
)
+ await client.bootstrap_accounts()
except aiohttp.ClientError:
errors["base"] = "cannot_connect"
else:
@@ -86,10 +93,17 @@ class OVOEnergyFlowHandler(ConfigFlow, domain=DOMAIN):
self.context["title_placeholders"] = {CONF_USERNAME: self.username}
if user_input is not None and user_input.get(CONF_PASSWORD) is not None:
- client = OVOEnergy()
+ client = OVOEnergy(
+ client_session=async_get_clientsession(self.hass),
+ )
+
+ if self.account is not None:
+ client.custom_account_id = self.account
+
try:
authenticated = await client.authenticate(
- self.username, user_input[CONF_PASSWORD], self.account
+ self.username,
+ user_input[CONF_PASSWORD],
)
except aiohttp.ClientError:
errors["base"] = "connection_error"
diff --git a/homeassistant/components/ovo_energy/manifest.json b/homeassistant/components/ovo_energy/manifest.json
index 9435958f1fe..af4a313206e 100644
--- a/homeassistant/components/ovo_energy/manifest.json
+++ b/homeassistant/components/ovo_energy/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["ovoenergy"],
- "requirements": ["ovoenergy==1.3.1"]
+ "requirements": ["ovoenergy==2.0.0"]
}
diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py
index d5384837e9c..5b16e8cdef5 100644
--- a/homeassistant/components/ovo_energy/sensor.py
+++ b/homeassistant/components/ovo_energy/sensor.py
@@ -7,8 +7,8 @@ import dataclasses
from datetime import datetime, timedelta
from typing import Final
+from ovoenergy import OVOEnergy
from ovoenergy.models import OVODailyUsage
-from ovoenergy.ovoenergy import OVOEnergy
from homeassistant.components.sensor import (
SensorDeviceClass,
diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json
index d193fd7487a..d51278d0c1b 100644
--- a/homeassistant/components/pegel_online/manifest.json
+++ b/homeassistant/components/pegel_online/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aiopegelonline"],
- "requirements": ["aiopegelonline==0.0.9"]
+ "requirements": ["aiopegelonline==0.0.10"]
}
diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json
index 85362371715..ff0ab39b150 100644
--- a/homeassistant/components/plex/manifest.json
+++ b/homeassistant/components/plex/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["plexapi", "plexwebsocket"],
"requirements": [
- "PlexAPI==4.15.11",
+ "PlexAPI==4.15.12",
"plexauth==0.0.6",
"plexwebsocket==0.0.14"
],
diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py
index 28389ffa357..3140e518688 100644
--- a/homeassistant/components/plugwise/__init__.py
+++ b/homeassistant/components/plugwise/__init__.py
@@ -49,8 +49,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None:
"""Migrate Plugwise entity entries.
- - Migrates unique ID from old relay switches to the new unique ID
+ - Migrates old unique ID's from old binary_sensors and switches to the new unique ID's
"""
+ if entry.domain == Platform.BINARY_SENSOR and entry.unique_id.endswith(
+ "-slave_boiler_state"
+ ):
+ return {
+ "new_unique_id": entry.unique_id.replace(
+ "-slave_boiler_state", "-secondary_boiler_state"
+ )
+ }
if entry.domain == Platform.SWITCH and entry.unique_id.endswith("-plug"):
return {"new_unique_id": entry.unique_id.replace("-plug", "-relay")}
diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py
index d32ae94160f..01ebc736dbe 100644
--- a/homeassistant/components/plugwise/binary_sensor.py
+++ b/homeassistant/components/plugwise/binary_sensor.py
@@ -64,8 +64,8 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseBinarySensorEntityDescription(
- key="slave_boiler_state",
- translation_key="slave_boiler_state",
+ key="secondary_boiler_state",
+ translation_key="secondary_boiler_state",
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseBinarySensorEntityDescription(
diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json
index 888f813760a..ada7d2d2533 100644
--- a/homeassistant/components/plugwise/manifest.json
+++ b/homeassistant/components/plugwise/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["plugwise"],
- "requirements": ["plugwise==0.37.1"],
+ "requirements": ["plugwise==0.37.3"],
"zeroconf": ["_plugwise._tcp.local."]
}
diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json
index 7d26f5a624c..ef2d6458441 100644
--- a/homeassistant/components/plugwise/strings.json
+++ b/homeassistant/components/plugwise/strings.json
@@ -48,7 +48,7 @@
"cooling_state": {
"name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]"
},
- "slave_boiler_state": {
+ "secondary_boiler_state": {
"name": "Secondary boiler state"
},
"plugwise_notification": {
diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py
index 7b1a38b7e31..84f080c4d49 100644
--- a/homeassistant/components/qbittorrent/__init__.py
+++ b/homeassistant/components/qbittorrent/__init__.py
@@ -1,29 +1,111 @@
"""The qbittorrent component."""
import logging
+from typing import Any
from qbittorrent.client import LoginRequired
from requests.exceptions import RequestException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
+ ATTR_DEVICE_ID,
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
+from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
+from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers.typing import ConfigType
-from .const import DOMAIN
+from .const import (
+ DOMAIN,
+ SERVICE_GET_ALL_TORRENTS,
+ SERVICE_GET_TORRENTS,
+ STATE_ATTR_ALL_TORRENTS,
+ STATE_ATTR_TORRENTS,
+ TORRENT_FILTER,
+)
from .coordinator import QBittorrentDataCoordinator
-from .helpers import setup_client
+from .helpers import format_torrents, setup_client
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
PLATFORMS = [Platform.SENSOR]
+CONF_ENTRY = "entry"
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up qBittorrent services."""
+
+ async def handle_get_torrents(service_call: ServiceCall) -> dict[str, Any] | None:
+ device_registry = dr.async_get(hass)
+ device_entry = device_registry.async_get(service_call.data[ATTR_DEVICE_ID])
+
+ if device_entry is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_device",
+ translation_placeholders={
+ "device_id": service_call.data[ATTR_DEVICE_ID]
+ },
+ )
+
+ entry_id = None
+
+ for key, value in device_entry.identifiers:
+ if key == DOMAIN:
+ entry_id = value
+ break
+ else:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_entry_id",
+ translation_placeholders={"device_id": entry_id or ""},
+ )
+
+ coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][entry_id]
+ items = await coordinator.get_torrents(service_call.data[TORRENT_FILTER])
+ info = format_torrents(items)
+ return {
+ STATE_ATTR_TORRENTS: info,
+ }
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_GET_TORRENTS,
+ handle_get_torrents,
+ supports_response=SupportsResponse.ONLY,
+ )
+
+ async def handle_get_all_torrents(
+ service_call: ServiceCall,
+ ) -> dict[str, Any] | None:
+ torrents = {}
+
+ for key, value in hass.data[DOMAIN].items():
+ coordinator: QBittorrentDataCoordinator = value
+ items = await coordinator.get_torrents(service_call.data[TORRENT_FILTER])
+ torrents[key] = format_torrents(items)
+
+ return {
+ STATE_ATTR_ALL_TORRENTS: torrents,
+ }
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_GET_ALL_TORRENTS,
+ handle_get_all_torrents,
+ supports_response=SupportsResponse.ONLY,
+ )
+
+ return True
+
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up qBittorrent from a config entry."""
diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py
index d8fe2c012a3..73e29d06f40 100644
--- a/homeassistant/components/qbittorrent/const.py
+++ b/homeassistant/components/qbittorrent/const.py
@@ -7,6 +7,13 @@ DOMAIN: Final = "qbittorrent"
DEFAULT_NAME = "qBittorrent"
DEFAULT_URL = "http://127.0.0.1:8080"
+STATE_ATTR_TORRENTS = "torrents"
+STATE_ATTR_ALL_TORRENTS = "all_torrents"
+
STATE_UP_DOWN = "up_down"
STATE_SEEDING = "seeding"
STATE_DOWNLOADING = "downloading"
+
+SERVICE_GET_TORRENTS = "get_torrents"
+SERVICE_GET_ALL_TORRENTS = "get_all_torrents"
+TORRENT_FILTER = "torrent_filter"
diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py
index 32ce4cf9711..850bcf15ca2 100644
--- a/homeassistant/components/qbittorrent/coordinator.py
+++ b/homeassistant/components/qbittorrent/coordinator.py
@@ -10,7 +10,7 @@ from qbittorrent import Client
from qbittorrent.client import LoginRequired
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryError
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
@@ -19,11 +19,18 @@ _LOGGER = logging.getLogger(__name__)
class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
- """Coordinator for updating QBittorrent data."""
+ """Coordinator for updating qBittorrent data."""
def __init__(self, hass: HomeAssistant, client: Client) -> None:
"""Initialize coordinator."""
self.client = client
+ # self.main_data: dict[str, int] = {}
+ self.total_torrents: dict[str, int] = {}
+ self.active_torrents: dict[str, int] = {}
+ self.inactive_torrents: dict[str, int] = {}
+ self.paused_torrents: dict[str, int] = {}
+ self.seeding_torrents: dict[str, int] = {}
+ self.started_torrents: dict[str, int] = {}
super().__init__(
hass,
@@ -33,7 +40,21 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
async def _async_update_data(self) -> dict[str, Any]:
+ """Async method to update QBittorrent data."""
try:
return await self.hass.async_add_executor_job(self.client.sync_main_data)
except LoginRequired as exc:
- raise ConfigEntryError("Invalid authentication") from exc
+ raise HomeAssistantError(str(exc)) from exc
+
+ async def get_torrents(self, torrent_filter: str) -> list[dict[str, Any]]:
+ """Async method to get QBittorrent torrents."""
+ try:
+ torrents = await self.hass.async_add_executor_job(
+ lambda: self.client.torrents(filter=torrent_filter)
+ )
+ except LoginRequired as exc:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="login_error"
+ ) from exc
+
+ return torrents
diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py
index b9c29675473..bbe53765f8b 100644
--- a/homeassistant/components/qbittorrent/helpers.py
+++ b/homeassistant/components/qbittorrent/helpers.py
@@ -1,5 +1,8 @@
"""Helper functions for qBittorrent."""
+from datetime import UTC, datetime
+from typing import Any
+
from qbittorrent.client import Client
@@ -10,3 +13,48 @@ def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Cl
# Get an arbitrary attribute to test if connection succeeds
client.get_alternative_speed_status()
return client
+
+
+def seconds_to_hhmmss(seconds) -> str:
+ """Convert seconds to HH:MM:SS format."""
+ if seconds == 8640000:
+ return "None"
+
+ minutes, seconds = divmod(seconds, 60)
+ hours, minutes = divmod(minutes, 60)
+ return f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}"
+
+
+def format_unix_timestamp(timestamp) -> str:
+ """Format a UNIX timestamp to a human-readable date."""
+ dt_object = datetime.fromtimestamp(timestamp, tz=UTC)
+ return dt_object.isoformat()
+
+
+def format_progress(torrent) -> str:
+ """Format the progress of a torrent."""
+ progress = torrent["progress"]
+ progress = float(progress) * 100
+ return f"{progress:.2f}"
+
+
+def format_torrents(torrents: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
+ """Format a list of torrents."""
+ value = {}
+ for torrent in torrents:
+ value[torrent["name"]] = format_torrent(torrent)
+
+ return value
+
+
+def format_torrent(torrent) -> dict[str, Any]:
+ """Format a single torrent."""
+ value = {}
+ value["id"] = torrent["hash"]
+ value["added_date"] = format_unix_timestamp(torrent["added_on"])
+ value["percent_done"] = format_progress(torrent)
+ value["status"] = torrent["state"]
+ value["eta"] = seconds_to_hhmmss(torrent["eta"])
+ value["ratio"] = "{:.2f}".format(float(torrent["ratio"]))
+
+ return value
diff --git a/homeassistant/components/qbittorrent/icons.json b/homeassistant/components/qbittorrent/icons.json
index bb458c751e1..68fc1020dae 100644
--- a/homeassistant/components/qbittorrent/icons.json
+++ b/homeassistant/components/qbittorrent/icons.json
@@ -8,5 +8,9 @@
"default": "mdi:cloud-upload"
}
}
+ },
+ "services": {
+ "get_torrents": "mdi:file-arrow-up-down-outline",
+ "get_all_torrents": "mdi:file-arrow-up-down-outline"
}
}
diff --git a/homeassistant/components/qbittorrent/services.yaml b/homeassistant/components/qbittorrent/services.yaml
new file mode 100644
index 00000000000..f7fc6b95f64
--- /dev/null
+++ b/homeassistant/components/qbittorrent/services.yaml
@@ -0,0 +1,35 @@
+get_torrents:
+ fields:
+ device_id:
+ required: true
+ selector:
+ device:
+ integration: qbittorrent
+ torrent_filter:
+ required: true
+ example: "all"
+ default: "all"
+ selector:
+ select:
+ options:
+ - "active"
+ - "inactive"
+ - "paused"
+ - "all"
+ - "seeding"
+ - "started"
+get_all_torrents:
+ fields:
+ torrent_filter:
+ required: true
+ example: "all"
+ default: "all"
+ selector:
+ select:
+ options:
+ - "active"
+ - "inactive"
+ - "paused"
+ - "all"
+ - "seeding"
+ - "started"
diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json
index 8b20a3354dd..5376e929429 100644
--- a/homeassistant/components/qbittorrent/strings.json
+++ b/homeassistant/components/qbittorrent/strings.json
@@ -48,5 +48,42 @@
"name": "All torrents"
}
}
+ },
+ "services": {
+ "get_torrents": {
+ "name": "Get torrents",
+ "description": "Gets a list of current torrents",
+ "fields": {
+ "device_id": {
+ "name": "[%key:common::config_flow::data::device%]",
+ "description": "Which service to grab the list from"
+ },
+ "torrent_filter": {
+ "name": "Torrent filter",
+ "description": "What kind of torrents you want to return, such as All or Active."
+ }
+ }
+ },
+ "get_all_torrents": {
+ "name": "Get all torrents",
+ "description": "Gets a list of current torrents from all instances of qBittorrent",
+ "fields": {
+ "torrent_filter": {
+ "name": "Torrent filter",
+ "description": "What kind of torrents you want to return, such as All or Active."
+ }
+ }
+ }
+ },
+ "exceptions": {
+ "invalid_device": {
+ "message": "No device with id {device_id} was found"
+ },
+ "invalid_entry_id": {
+ "message": "No entry with id {device_id} was found"
+ },
+ "login_error": {
+ "message": "A login error occured. Please check you username and password."
+ }
}
}
diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py
index b4d719a9481..2be02fe8091 100644
--- a/homeassistant/components/recorder/services.py
+++ b/homeassistant/components/recorder/services.py
@@ -7,6 +7,7 @@ from typing import cast
import voluptuous as vol
+from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import generate_filter
@@ -36,15 +37,28 @@ SERVICE_PURGE_SCHEMA = vol.Schema(
ATTR_DOMAINS = "domains"
ATTR_ENTITY_GLOBS = "entity_globs"
-SERVICE_PURGE_ENTITIES_SCHEMA = vol.Schema(
- {
- vol.Optional(ATTR_DOMAINS, default=[]): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(ATTR_ENTITY_GLOBS, default=[]): vol.All(
- cv.ensure_list, [cv.string]
+SERVICE_PURGE_ENTITIES_SCHEMA = vol.All(
+ vol.Schema(
+ {
+ vol.Optional(ATTR_ENTITY_ID, default=[]): cv.entity_ids,
+ vol.Optional(ATTR_DOMAINS, default=[]): vol.All(
+ cv.ensure_list, [cv.string]
+ ),
+ vol.Optional(ATTR_ENTITY_GLOBS, default=[]): vol.All(
+ cv.ensure_list, [cv.string]
+ ),
+ vol.Optional(ATTR_KEEP_DAYS, default=0): cv.positive_int,
+ }
+ ),
+ vol.Any(
+ vol.Schema({vol.Required(ATTR_ENTITY_ID): vol.IsTrue()}, extra=vol.ALLOW_EXTRA),
+ vol.Schema({vol.Required(ATTR_DOMAINS): vol.IsTrue()}, extra=vol.ALLOW_EXTRA),
+ vol.Schema(
+ {vol.Required(ATTR_ENTITY_GLOBS): vol.IsTrue()}, extra=vol.ALLOW_EXTRA
),
- vol.Optional(ATTR_KEEP_DAYS, default=0): cv.positive_int,
- }
-).extend(cv.ENTITY_SERVICE_FIELDS)
+ msg="At least one of entity_id, domains, or entity_globs must have a value",
+ ),
+)
SERVICE_ENABLE_SCHEMA = vol.Schema({})
SERVICE_DISABLE_SCHEMA = vol.Schema({})
diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml
index b74dcc2a494..7d7b926548c 100644
--- a/homeassistant/components/recorder/services.yaml
+++ b/homeassistant/components/recorder/services.yaml
@@ -20,20 +20,21 @@ purge:
boolean:
purge_entities:
- target:
- entity: {}
fields:
+ entity_id:
+ required: false
+ selector:
+ entity:
+ multiple: true
domains:
example: "sun"
required: false
- default: []
selector:
object:
entity_globs:
example: "domain*.object_id*"
required: false
- default: []
selector:
object:
diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json
index 74b248354d7..bf5d95ae1fc 100644
--- a/homeassistant/components/recorder/strings.json
+++ b/homeassistant/components/recorder/strings.json
@@ -41,6 +41,10 @@
"name": "Purge entities",
"description": "Starts a purge task to remove the data related to specific entities from your database.",
"fields": {
+ "entity_id": {
+ "name": "Entities to remove",
+ "description": "List of entities for which the data is to be removed from the recorder database."
+ },
"domains": {
"name": "Domains to remove",
"description": "List of domains for which the data needs to be removed from the recorder database."
diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py
index f6c8f73d24b..eb79e197937 100644
--- a/homeassistant/components/renault/select.py
+++ b/homeassistant/components/renault/select.py
@@ -71,6 +71,6 @@ SENSOR_TYPES: tuple[RenaultSelectEntityDescription, ...] = (
coordinator="charge_mode",
data_key="chargeMode",
translation_key="charge_mode",
- options=["always", "always_charging", "schedule_mode"],
+ options=["always", "always_charging", "schedule_mode", "scheduled"],
),
)
diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json
index ec902855f27..bb3701e2e31 100644
--- a/homeassistant/components/rfxtrx/manifest.json
+++ b/homeassistant/components/rfxtrx/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/rfxtrx",
"iot_class": "local_push",
"loggers": ["RFXtrx"],
- "requirements": ["pyRFXtrx==0.31.0"]
+ "requirements": ["pyRFXtrx==0.31.1"]
}
diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json
index 4c590b95e52..22e73a10d6d 100644
--- a/homeassistant/components/risco/manifest.json
+++ b/homeassistant/components/risco/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "local_push",
"loggers": ["pyrisco"],
"quality_scale": "platinum",
- "requirements": ["pyrisco==0.6.0"]
+ "requirements": ["pyrisco==0.6.1"]
}
diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py
index f4d6ddaf451..8f97c76c879 100644
--- a/homeassistant/components/risco/sensor.py
+++ b/homeassistant/components/risco/sensor.py
@@ -56,8 +56,8 @@ async def async_setup_entry(
config_entry.entry_id
][EVENTS_COORDINATOR]
sensors = [
- RiscoSensor(coordinator, id, [], name, config_entry.entry_id)
- for id, name in CATEGORIES.items()
+ RiscoSensor(coordinator, category_id, [], name, config_entry.entry_id)
+ for category_id, name in CATEGORIES.items()
]
sensors.append(
RiscoSensor(
diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py
index b72fec5a8e1..12a884dba48 100644
--- a/homeassistant/components/roborock/__init__.py
+++ b/homeassistant/components/roborock/__init__.py
@@ -107,7 +107,9 @@ async def setup_device(
home_data_rooms: list[HomeDataRoom],
) -> RoborockDataUpdateCoordinator | None:
"""Set up a device Coordinator."""
- mqtt_client = RoborockMqttClientV1(user_data, DeviceData(device, product_info.name))
+ mqtt_client = RoborockMqttClientV1(
+ user_data, DeviceData(device, product_info.model)
+ )
try:
networking = await mqtt_client.get_networking()
if networking is None:
diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py
index 69384d6e23a..6450d849859 100644
--- a/homeassistant/components/roborock/device.py
+++ b/homeassistant/components/roborock/device.py
@@ -137,4 +137,4 @@ class RoborockCoordinatedEntity(
else:
self.coordinator.roborock_device_info.props.consumable = value
self.coordinator.data = self.coordinator.roborock_device_info.props
- self.async_write_ha_state()
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py
index d8108abf78c..16cf518aa02 100644
--- a/homeassistant/components/roborock/vacuum.py
+++ b/homeassistant/components/roborock/vacuum.py
@@ -178,4 +178,8 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity):
async def get_maps(self) -> ServiceResponse:
"""Get map information such as map id and room ids."""
- return {"maps": [asdict(map) for map in self.coordinator.maps.values()]}
+ return {
+ "maps": [
+ asdict(vacuum_map) for vacuum_map in self.coordinator.maps.values()
+ ]
+ }
diff --git a/homeassistant/components/romy/binary_sensor.py b/homeassistant/components/romy/binary_sensor.py
new file mode 100644
index 00000000000..d8f6216007f
--- /dev/null
+++ b/homeassistant/components/romy/binary_sensor.py
@@ -0,0 +1,73 @@
+"""Checking binary status values from your ROMY."""
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import RomyVacuumCoordinator
+from .entity import RomyEntity
+
+BINARY_SENSORS: list[BinarySensorEntityDescription] = [
+ BinarySensorEntityDescription(
+ key="dustbin",
+ translation_key="dustbin_present",
+ ),
+ BinarySensorEntityDescription(
+ key="dock",
+ translation_key="docked",
+ device_class=BinarySensorDeviceClass.PLUG,
+ ),
+ BinarySensorEntityDescription(
+ key="water_tank",
+ translation_key="water_tank_present",
+ device_class=BinarySensorDeviceClass.MOISTURE,
+ ),
+ BinarySensorEntityDescription(
+ key="water_tank_empty",
+ translation_key="water_tank_empty",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up ROMY vacuum cleaner."""
+
+ coordinator: RomyVacuumCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+
+ async_add_entities(
+ RomyBinarySensor(coordinator, entity_description)
+ for entity_description in BINARY_SENSORS
+ if entity_description.key in coordinator.romy.binary_sensors
+ )
+
+
+class RomyBinarySensor(RomyEntity, BinarySensorEntity):
+ """RomyBinarySensor Class."""
+
+ entity_description: BinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: RomyVacuumCoordinator,
+ entity_description: BinarySensorEntityDescription,
+ ) -> None:
+ """Initialize the RomyBinarySensor."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{entity_description.key}_{self.romy.unique_id}"
+ self.entity_description = entity_description
+
+ @property
+ def is_on(self) -> bool:
+ """Return the value of the sensor."""
+ return bool(self.romy.binary_sensors[self.entity_description.key])
diff --git a/homeassistant/components/romy/const.py b/homeassistant/components/romy/const.py
index 5d42380902b..a41482ffe59 100644
--- a/homeassistant/components/romy/const.py
+++ b/homeassistant/components/romy/const.py
@@ -6,6 +6,6 @@ import logging
from homeassistant.const import Platform
DOMAIN = "romy"
-PLATFORMS = [Platform.VACUUM]
+PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.VACUUM]
UPDATE_INTERVAL = timedelta(seconds=5)
LOGGER = logging.getLogger(__package__)
diff --git a/homeassistant/components/romy/icons.json b/homeassistant/components/romy/icons.json
new file mode 100644
index 00000000000..3425d5cfade
--- /dev/null
+++ b/homeassistant/components/romy/icons.json
@@ -0,0 +1,37 @@
+{
+ "entity": {
+ "binary_sensor": {
+ "water_tank_empty": {
+ "default": "mdi:cup-outline",
+ "state": {
+ "off": "mdi:cup-water",
+ "on": "mdi:cup-outline"
+ }
+ },
+ "dustbin_present": {
+ "default": "mdi:basket-check",
+ "state": {
+ "off": "mdi:basket-remove",
+ "on": "mdi:basket-check"
+ }
+ }
+ },
+ "sensor": {
+ "dustbin_sensor": {
+ "default": "mdi:basket-fill"
+ },
+ "total_cleaning_time": {
+ "default": "mdi:clock"
+ },
+ "total_number_of_cleaning_runs": {
+ "default": "mdi:counter"
+ },
+ "total_area_cleaned": {
+ "default": "mdi:texture-box"
+ },
+ "total_distance_driven": {
+ "default": "mdi:run"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/romy/sensor.py b/homeassistant/components/romy/sensor.py
new file mode 100644
index 00000000000..bdd486c4f8f
--- /dev/null
+++ b/homeassistant/components/romy/sensor.py
@@ -0,0 +1,112 @@
+"""Sensor checking adc and status values from your ROMY."""
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ AREA_SQUARE_METERS,
+ PERCENTAGE,
+ SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ EntityCategory,
+ UnitOfLength,
+ UnitOfTime,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import RomyVacuumCoordinator
+from .entity import RomyEntity
+
+SENSORS: list[SensorEntityDescription] = [
+ SensorEntityDescription(
+ key="battery_level",
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="rssi",
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="dustbin_sensor",
+ translation_key="dustbin_sensor",
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="total_cleaning_time",
+ translation_key="total_cleaning_time",
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfTime.HOURS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="total_number_of_cleaning_runs",
+ translation_key="total_number_of_cleaning_runs",
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement="runs",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="total_area_cleaned",
+ translation_key="total_area_cleaned",
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=AREA_SQUARE_METERS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="total_distance_driven",
+ translation_key="total_distance_driven",
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfLength.METERS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up ROMY vacuum cleaner."""
+
+ coordinator: RomyVacuumCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+
+ async_add_entities(
+ RomySensor(coordinator, entity_description)
+ for entity_description in SENSORS
+ if entity_description.key in coordinator.romy.sensors
+ )
+
+
+class RomySensor(RomyEntity, SensorEntity):
+ """RomySensor Class."""
+
+ entity_description: SensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: RomyVacuumCoordinator,
+ entity_description: SensorEntityDescription,
+ ) -> None:
+ """Initialize ROMYs StatusSensor."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{entity_description.key}_{self.romy.unique_id}"
+ self.entity_description = entity_description
+
+ @property
+ def native_value(self) -> int:
+ """Return the value of the sensor."""
+ value: int = self.romy.sensors[self.entity_description.key]
+ return value
diff --git a/homeassistant/components/romy/strings.json b/homeassistant/components/romy/strings.json
index 26dc60a2e84..78721da17ba 100644
--- a/homeassistant/components/romy/strings.json
+++ b/homeassistant/components/romy/strings.json
@@ -46,6 +46,37 @@
}
}
}
+ },
+ "binary_sensor": {
+ "dustbin_present": {
+ "name": "Dustbin present"
+ },
+ "docked": {
+ "name": "Robot docked"
+ },
+ "water_tank_present": {
+ "name": "Watertank present"
+ },
+ "water_tank_empty": {
+ "name": "Watertank empty"
+ }
+ },
+ "sensor": {
+ "dustbin_sensor": {
+ "name": "Dustbin dirt level"
+ },
+ "total_cleaning_time": {
+ "name": "Total cleaning time"
+ },
+ "total_number_of_cleaning_runs": {
+ "name": "Total cleaning runs"
+ },
+ "total_area_cleaned": {
+ "name": "Total cleaned area"
+ },
+ "total_distance_driven": {
+ "name": "Total distance driven"
+ }
}
}
}
diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py
index 8d2e47315ef..debff5a6e96 100644
--- a/homeassistant/components/rss_feed_template/__init__.py
+++ b/homeassistant/components/rss_feed_template/__init__.py
@@ -91,9 +91,7 @@ class RssView(HomeAssistantView):
response += '
\n'
response += " \n"
if self._title is not None:
- response += " %s\n" % escape(
- self._title.async_render(parse_result=False)
- )
+ response += f" {escape(self._title.async_render(parse_result=False))}\n"
else:
response += " Home Assistant\n"
diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py
index 36715c44a9b..ff347431a4a 100644
--- a/homeassistant/components/samsungtv/media_player.py
+++ b/homeassistant/components/samsungtv/media_player.py
@@ -46,15 +46,17 @@ from .triggers.turn_on import async_get_turn_on_trigger
SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"}
SUPPORT_SAMSUNGTV = (
- MediaPlayerEntityFeature.PAUSE
- | MediaPlayerEntityFeature.VOLUME_STEP
- | MediaPlayerEntityFeature.VOLUME_MUTE
- | MediaPlayerEntityFeature.PREVIOUS_TRACK
- | MediaPlayerEntityFeature.SELECT_SOURCE
- | MediaPlayerEntityFeature.NEXT_TRACK
- | MediaPlayerEntityFeature.TURN_OFF
+ MediaPlayerEntityFeature.NEXT_TRACK
+ | MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
+ | MediaPlayerEntityFeature.PREVIOUS_TRACK
+ | MediaPlayerEntityFeature.SELECT_SOURCE
+ | MediaPlayerEntityFeature.STOP
+ | MediaPlayerEntityFeature.TURN_OFF
+ | MediaPlayerEntityFeature.VOLUME_MUTE
+ | MediaPlayerEntityFeature.VOLUME_SET
+ | MediaPlayerEntityFeature.VOLUME_STEP
)
diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py
index 1d06e1a24c4..a955e861c20 100644
--- a/homeassistant/components/sensor/__init__.py
+++ b/homeassistant/components/sensor/__init__.py
@@ -747,13 +747,15 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return value
- def _suggested_precision_or_none(self) -> int | None:
- """Return suggested display precision, or None if not set."""
+ def _display_precision_or_none(self) -> int | None:
+ """Return display precision, or None if not set."""
assert self.registry_entry
- if (sensor_options := self.registry_entry.options.get(DOMAIN)) and (
- precision := sensor_options.get("suggested_display_precision")
- ) is not None:
- return cast(int, precision)
+ if not (sensor_options := self.registry_entry.options.get(DOMAIN)):
+ return None
+
+ for option in ("display_precision", "suggested_display_precision"):
+ if (precision := sensor_options.get(option)) is not None:
+ return cast(int, precision)
return None
def _update_suggested_precision(self) -> None:
@@ -784,11 +786,6 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
ratio_log = floor(ratio_log) if ratio_log > 0 else ceil(ratio_log)
display_precision = max(0, display_precision + ratio_log)
- if display_precision is None and (
- DOMAIN not in self.registry_entry.options
- or "suggested_display_precision" not in self.registry_entry.options
- ):
- return
sensor_options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {})
if (
"suggested_display_precision" in sensor_options
@@ -835,7 +832,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Called when the entity registry entry has been updated and before the sensor is
added to the state machine.
"""
- self._sensor_option_display_precision = self._suggested_precision_or_none()
+ self._sensor_option_display_precision = self._display_precision_or_none()
assert self.registry_entry
if (
sensor_options := self.registry_entry.options.get(f"{DOMAIN}.private")
diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py
index 183d1bd4068..40c9c8d58d1 100644
--- a/homeassistant/components/seventeentrack/__init__.py
+++ b/homeassistant/components/seventeentrack/__init__.py
@@ -4,14 +4,80 @@ from py17track import Client as SeventeenTrackClient
from py17track.errors import SeventeenTrackError
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.const import (
+ ATTR_FRIENDLY_NAME,
+ ATTR_LOCATION,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ Platform,
+)
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+)
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import slugify
-from .const import DOMAIN
+from .const import (
+ ATTR_CONFIG_ENTRY_ID,
+ ATTR_INFO_TEXT,
+ ATTR_PACKAGE_STATE,
+ ATTR_STATUS,
+ ATTR_TIMESTAMP,
+ ATTR_TRACKING_NUMBER,
+ DOMAIN,
+ SERVICE_GET_PACKAGES,
+)
+from .coordinator import SeventeenTrackCoordinator
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the 17Track component."""
+
+ async def get_packages(call: ServiceCall) -> ServiceResponse:
+ """Get packages from 17Track."""
+ config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
+ package_states = call.data.get(ATTR_PACKAGE_STATE, [])
+ seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][
+ config_entry_id
+ ]
+ live_packages = sorted(
+ await seventeen_coordinator.client.profile.packages(
+ show_archived=seventeen_coordinator.show_archived
+ )
+ )
+
+ return {
+ "packages": [
+ {
+ ATTR_TRACKING_NUMBER: package.tracking_number,
+ ATTR_LOCATION: package.location,
+ ATTR_STATUS: package.status,
+ ATTR_TIMESTAMP: package.timestamp,
+ ATTR_INFO_TEXT: package.info_text,
+ ATTR_FRIENDLY_NAME: package.friendly_name,
+ }
+ for package in live_packages
+ if slugify(package.status) in package_states or package_states == []
+ ]
+ }
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_GET_PACKAGES,
+ get_packages,
+ supports_response=SupportsResponse.ONLY,
+ )
+ return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -25,8 +91,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except SeventeenTrackError as err:
raise ConfigEntryNotReady from err
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client
+ seventeen_coordinator = SeventeenTrackCoordinator(hass, client)
+ await seventeen_coordinator.async_config_entry_first_refresh()
+
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = seventeen_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
-
return True
diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py
index 6f8ae1b221c..39932d31935 100644
--- a/homeassistant/components/seventeentrack/const.py
+++ b/homeassistant/components/seventeentrack/const.py
@@ -1,6 +1,9 @@
"""Constants for the 17track.net component."""
from datetime import timedelta
+import logging
+
+LOGGER = logging.getLogger(__package__)
ATTR_DESTINATION_COUNTRY = "destination_country"
ATTR_INFO_TEXT = "info_text"
@@ -37,3 +40,8 @@ NOTIFICATION_DELIVERED_MESSAGE = (
)
VALUE_DELIVERED = "Delivered"
+
+SERVICE_GET_PACKAGES = "get_packages"
+
+ATTR_PACKAGE_STATE = "package_state"
+ATTR_CONFIG_ENTRY_ID = "config_entry_id"
diff --git a/homeassistant/components/seventeentrack/coordinator.py b/homeassistant/components/seventeentrack/coordinator.py
new file mode 100644
index 00000000000..4da4969ed92
--- /dev/null
+++ b/homeassistant/components/seventeentrack/coordinator.py
@@ -0,0 +1,84 @@
+"""Coordinator for 17Track."""
+
+from dataclasses import dataclass
+from typing import Any
+
+from py17track import Client as SeventeenTrackClient
+from py17track.errors import SeventeenTrackError
+from py17track.package import Package
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util import slugify
+
+from .const import (
+ CONF_SHOW_ARCHIVED,
+ CONF_SHOW_DELIVERED,
+ DEFAULT_SCAN_INTERVAL,
+ DOMAIN,
+ LOGGER,
+)
+
+
+@dataclass
+class SeventeenTrackData:
+ """Class for handling the data retrieval."""
+
+ summary: dict[str, dict[str, Any]]
+ live_packages: dict[str, Package]
+
+
+class SeventeenTrackCoordinator(DataUpdateCoordinator[SeventeenTrackData]):
+ """Class to manage fetching 17Track data."""
+
+ config_entry: ConfigEntry
+
+ def __init__(self, hass: HomeAssistant, client: SeventeenTrackClient) -> None:
+ """Initialize."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=DOMAIN,
+ update_interval=DEFAULT_SCAN_INTERVAL,
+ )
+ self.show_delivered = self.config_entry.options[CONF_SHOW_DELIVERED]
+ self.account_id = client.profile.account_id
+
+ self.show_archived = self.config_entry.options[CONF_SHOW_ARCHIVED]
+ self.client = client
+
+ async def _async_update_data(self) -> SeventeenTrackData:
+ """Fetch data from 17Track API."""
+
+ try:
+ summary = await self.client.profile.summary(
+ show_archived=self.show_archived
+ )
+
+ live_packages = set(
+ await self.client.profile.packages(show_archived=self.show_archived)
+ )
+
+ except SeventeenTrackError as err:
+ raise UpdateFailed(err) from err
+
+ summary_dict = {}
+ live_packages_dict = {}
+
+ for status, quantity in summary.items():
+ summary_dict[slugify(status)] = {
+ "quantity": quantity,
+ "packages": [],
+ "status_name": status,
+ }
+
+ for package in live_packages:
+ live_packages_dict[package.tracking_number] = package
+ summary_value = summary_dict.get(slugify(package.status))
+ if summary_value:
+ summary_value["packages"].append(package)
+
+ return SeventeenTrackData(
+ summary=summary_dict, live_packages=live_packages_dict
+ )
diff --git a/homeassistant/components/seventeentrack/icons.json b/homeassistant/components/seventeentrack/icons.json
new file mode 100644
index 00000000000..78ca65edc4d
--- /dev/null
+++ b/homeassistant/components/seventeentrack/icons.json
@@ -0,0 +1,33 @@
+{
+ "entity": {
+ "sensor": {
+ "not_found": {
+ "default": "mdi:package"
+ },
+ "in_transit": {
+ "default": "mdi:package"
+ },
+ "expired": {
+ "default": "mdi:package"
+ },
+ "ready_to_be_picked_up": {
+ "default": "mdi:package"
+ },
+ "undelivered": {
+ "default": "mdi:package"
+ },
+ "delivered": {
+ "default": "mdi:package"
+ },
+ "returned": {
+ "default": "mdi:package"
+ },
+ "package": {
+ "default": "mdi:package"
+ }
+ }
+ },
+ "services": {
+ "get_packages": "mdi:package"
+ }
+}
diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py
index 1de627fab39..acc8471c030 100644
--- a/homeassistant/components/seventeentrack/sensor.py
+++ b/homeassistant/components/seventeentrack/sensor.py
@@ -2,10 +2,8 @@
from __future__ import annotations
-import logging
+from typing import Any
-from py17track.errors import SeventeenTrackError
-from py17track.package import Package
import voluptuous as vol
from homeassistant.components import persistent_notification
@@ -17,15 +15,16 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
-from homeassistant.helpers import config_validation as cv, entity, entity_registry as er
+from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
-from homeassistant.util import Throttle, slugify
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from . import SeventeenTrackCoordinator
from .const import (
ATTR_DESTINATION_COUNTRY,
ATTR_INFO_TEXT,
@@ -39,17 +38,14 @@ from .const import (
ATTRIBUTION,
CONF_SHOW_ARCHIVED,
CONF_SHOW_DELIVERED,
- DEFAULT_SCAN_INTERVAL,
DOMAIN,
- ENTITY_ID_TEMPLATE,
+ LOGGER,
NOTIFICATION_DELIVERED_MESSAGE,
NOTIFICATION_DELIVERED_TITLE,
UNIQUE_ID_TEMPLATE,
VALUE_DELIVERED,
)
-_LOGGER = logging.getLogger(__name__)
-
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_USERNAME): cv.string,
@@ -111,81 +107,158 @@ async def async_setup_entry(
) -> None:
"""Set up a 17Track sensor entry."""
- client = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ previous_tracking_numbers: set[str] = set()
- data = SeventeenTrackData(
- client,
- async_add_entities,
- DEFAULT_SCAN_INTERVAL,
- config_entry.options[CONF_SHOW_ARCHIVED],
- config_entry.options[CONF_SHOW_DELIVERED],
- str(hass.config.time_zone),
+ @callback
+ def _async_create_remove_entities():
+ live_tracking_numbers = set(coordinator.data.live_packages.keys())
+
+ new_tracking_numbers = live_tracking_numbers - previous_tracking_numbers
+ old_tracking_numbers = previous_tracking_numbers - live_tracking_numbers
+
+ previous_tracking_numbers.update(live_tracking_numbers)
+
+ packages_to_add = [
+ coordinator.data.live_packages[tracking_number]
+ for tracking_number in new_tracking_numbers
+ ]
+
+ for package_data in coordinator.data.live_packages.values():
+ if (
+ package_data.status == VALUE_DELIVERED
+ and not coordinator.show_delivered
+ ):
+ old_tracking_numbers.add(package_data.tracking_number)
+ notify_delivered(
+ hass,
+ package_data.friendly_name,
+ package_data.tracking_number,
+ )
+
+ remove_packages(hass, coordinator.account_id, old_tracking_numbers)
+
+ async_add_entities(
+ SeventeenTrackPackageSensor(
+ coordinator,
+ package_data.tracking_number,
+ )
+ for package_data in packages_to_add
+ if not (
+ not coordinator.show_delivered and package_data.status == "Delivered"
+ )
+ )
+
+ async_add_entities(
+ SeventeenTrackSummarySensor(status, coordinator)
+ for status, summary_data in coordinator.data.summary.items()
+ )
+
+ _async_create_remove_entities()
+
+ config_entry.async_on_unload(
+ coordinator.async_add_listener(_async_create_remove_entities)
)
- await data.async_update()
-class SeventeenTrackSummarySensor(SensorEntity):
- """Define a summary sensor."""
+class SeventeenTrackSensor(CoordinatorEntity[SeventeenTrackCoordinator], SensorEntity):
+ """Define a 17Track sensor."""
_attr_attribution = ATTRIBUTION
- _attr_icon = "mdi:package"
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: SeventeenTrackCoordinator) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, coordinator.account_id)},
+ entry_type=DeviceEntryType.SERVICE,
+ name="17Track",
+ )
+
+
+class SeventeenTrackSummarySensor(SeventeenTrackSensor):
+ """Define a summary sensor."""
+
_attr_native_unit_of_measurement = "packages"
- def __init__(self, data, status, initial_state) -> None:
- """Initialize."""
- self._attr_extra_state_attributes = {}
- self._data = data
- self._state = initial_state
+ def __init__(
+ self,
+ status: str,
+ coordinator: SeventeenTrackCoordinator,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
self._status = status
- self._attr_name = f"Seventeentrack Packages {status}"
- self._attr_unique_id = f"summary_{data.account_id}_{slugify(status)}"
+ self._attr_translation_key = status
+ self._attr_unique_id = f"summary_{coordinator.account_id}_{status}"
@property
def available(self) -> bool:
"""Return whether the entity is available."""
- return self._state is not None
+ return self._status in self.coordinator.data.summary
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ return self.coordinator.data.summary[self._status]["quantity"]
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any] | None:
+ """Return the state attributes."""
+ packages = self.coordinator.data.summary[self._status]["packages"]
+ return {
+ ATTR_PACKAGES: [
+ {
+ ATTR_TRACKING_NUMBER: package.tracking_number,
+ ATTR_LOCATION: package.location,
+ ATTR_STATUS: package.status,
+ ATTR_TIMESTAMP: package.timestamp,
+ ATTR_INFO_TEXT: package.info_text,
+ ATTR_FRIENDLY_NAME: package.friendly_name,
+ }
+ for package in packages
+ ]
+ }
+
+
+class SeventeenTrackPackageSensor(SeventeenTrackSensor):
+ """Define an individual package sensor."""
+
+ _attr_translation_key = "package"
+
+ def __init__(
+ self,
+ coordinator: SeventeenTrackCoordinator,
+ tracking_number: str,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self._tracking_number = tracking_number
+ self._previous_status = coordinator.data.live_packages[tracking_number].status
+ self._attr_unique_id = UNIQUE_ID_TEMPLATE.format(
+ coordinator.account_id, tracking_number
+ )
+ package = coordinator.data.live_packages[tracking_number]
+ if not (name := package.friendly_name):
+ name = tracking_number
+ self._attr_translation_placeholders = {"name": name}
+
+ @property
+ def available(self) -> bool:
+ """Return whether the entity is available."""
+ return self._tracking_number in self.coordinator.data.live_packages
@property
def native_value(self) -> StateType:
"""Return the state."""
- return self._state
+ return self.coordinator.data.live_packages[self._tracking_number].status
- async def async_update(self) -> None:
- """Update the sensor."""
- await self._data.async_update()
-
- package_data = []
- for package in self._data.packages.values():
- if package.status != self._status:
- continue
-
- package_data.append(
- {
- ATTR_FRIENDLY_NAME: package.friendly_name,
- ATTR_INFO_TEXT: package.info_text,
- ATTR_TIMESTAMP: package.timestamp,
- ATTR_STATUS: package.status,
- ATTR_LOCATION: package.location,
- ATTR_TRACKING_NUMBER: package.tracking_number,
- }
- )
-
- self._attr_extra_state_attributes[ATTR_PACKAGES] = (
- package_data if package_data else None
- )
-
- self._state = self._data.summary.get(self._status)
-
-
-class SeventeenTrackPackageSensor(SensorEntity):
- """Define an individual package sensor."""
-
- _attr_attribution = ATTRIBUTION
- _attr_icon = "mdi:package"
-
- def __init__(self, data, package) -> None:
- """Initialize."""
- self._attr_extra_state_attributes = {
+ @property
+ def extra_state_attributes(self) -> dict[str, Any] | None:
+ """Return the state attributes."""
+ package = self.coordinator.data.live_packages[self._tracking_number]
+ return {
ATTR_DESTINATION_COUNTRY: package.destination_country,
ATTR_INFO_TEXT: package.info_text,
ATTR_TIMESTAMP: package.timestamp,
@@ -195,158 +268,30 @@ class SeventeenTrackPackageSensor(SensorEntity):
ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language,
ATTR_TRACKING_NUMBER: package.tracking_number,
}
- self._data = data
- self._friendly_name = package.friendly_name
- self._state = package.status
- self._tracking_number = package.tracking_number
- self.entity_id = ENTITY_ID_TEMPLATE.format(self._tracking_number)
- self._attr_unique_id = UNIQUE_ID_TEMPLATE.format(
- data.account_id, self._tracking_number
- )
- @property
- def available(self) -> bool:
- """Return whether the entity is available."""
- return self._data.packages.get(self._tracking_number) is not None
- @property
- def name(self) -> str:
- """Return the name."""
- if not (name := self._friendly_name):
- name = self._tracking_number
- return f"Seventeentrack Package: {name}"
-
- @property
- def native_value(self) -> StateType:
- """Return the state."""
- return self._state
-
- async def async_update(self) -> None:
- """Update the sensor."""
- await self._data.async_update()
-
- if not self.available:
- # Entity cannot be removed while its being added
- async_call_later(self.hass, 1, self._remove)
- return
-
- package = self._data.packages.get(self._tracking_number, None)
-
- # If the user has elected to not see delivered packages and one gets
- # delivered, post a notification:
- if package.status == VALUE_DELIVERED and not self._data.show_delivered:
- self._notify_delivered()
- # Entity cannot be removed while its being added
- async_call_later(self.hass, 1, self._remove)
- return
-
- self._attr_extra_state_attributes.update(
- {
- ATTR_INFO_TEXT: package.info_text,
- ATTR_TIMESTAMP: package.timestamp,
- ATTR_LOCATION: package.location,
- }
- )
- self._state = package.status
- self._friendly_name = package.friendly_name
-
- async def _remove(self, *_):
- """Remove entity itself."""
- await self.async_remove(force_remove=True)
-
- reg = er.async_get(self.hass)
+def remove_packages(hass: HomeAssistant, account_id: str, packages: set[str]) -> None:
+ """Remove entity itself."""
+ reg = er.async_get(hass)
+ for package in packages:
entity_id = reg.async_get_entity_id(
"sensor",
"seventeentrack",
- UNIQUE_ID_TEMPLATE.format(self._data.account_id, self._tracking_number),
+ UNIQUE_ID_TEMPLATE.format(account_id, package),
)
if entity_id:
reg.async_remove(entity_id)
- def _notify_delivered(self):
- """Notify when package is delivered."""
- _LOGGER.info("Package delivered: %s", self._tracking_number)
- identification = (
- self._friendly_name if self._friendly_name else self._tracking_number
- )
- message = NOTIFICATION_DELIVERED_MESSAGE.format(
- identification, self._tracking_number
- )
- title = NOTIFICATION_DELIVERED_TITLE.format(identification)
- notification_id = NOTIFICATION_DELIVERED_TITLE.format(self._tracking_number)
+def notify_delivered(hass: HomeAssistant, friendly_name: str, tracking_number: str):
+ """Notify when package is delivered."""
+ LOGGER.debug("Package delivered: %s", tracking_number)
- persistent_notification.create(
- self.hass, message, title=title, notification_id=notification_id
- )
+ identification = friendly_name if friendly_name else tracking_number
+ message = NOTIFICATION_DELIVERED_MESSAGE.format(identification, tracking_number)
+ title = NOTIFICATION_DELIVERED_TITLE.format(identification)
+ notification_id = NOTIFICATION_DELIVERED_TITLE.format(tracking_number)
-
-class SeventeenTrackData:
- """Define a data handler for 17track.net."""
-
- def __init__(
- self,
- client,
- async_add_entities,
- scan_interval,
- show_archived,
- show_delivered,
- timezone,
- ) -> None:
- """Initialize."""
- self._async_add_entities = async_add_entities
- self._client = client
- self._scan_interval = scan_interval
- self._show_archived = show_archived
- self.account_id = client.profile.account_id
- self.packages: dict[str, Package] = {}
- self.show_delivered = show_delivered
- self.timezone = timezone
- self.summary: dict[str, int] = {}
- self.async_update = Throttle(self._scan_interval)(self._async_update)
- self.first_update = True
-
- async def _async_update(self):
- """Get updated data from 17track.net."""
- entities: list[entity.Entity] = []
-
- try:
- packages = await self._client.profile.packages(
- show_archived=self._show_archived, tz=self.timezone
- )
- _LOGGER.debug("New package data received: %s", packages)
-
- new_packages = {p.tracking_number: p for p in packages}
-
- to_add = set(new_packages) - set(self.packages)
-
- _LOGGER.debug("Will add new tracking numbers: %s", to_add)
- if to_add:
- entities.extend(
- SeventeenTrackPackageSensor(self, new_packages[tracking_number])
- for tracking_number in to_add
- )
-
- self.packages = new_packages
- except SeventeenTrackError as err:
- _LOGGER.error("There was an error retrieving packages: %s", err)
-
- try:
- self.summary = await self._client.profile.summary(
- show_archived=self._show_archived
- )
- _LOGGER.debug("New summary data received: %s", self.summary)
-
- # creating summary sensors on first update
- if self.first_update:
- self.first_update = False
- entities.extend(
- SeventeenTrackSummarySensor(self, status, quantity)
- for status, quantity in self.summary.items()
- )
-
- except SeventeenTrackError as err:
- _LOGGER.error("There was an error retrieving the summary: %s", err)
- self.summary = {}
-
- self._async_add_entities(entities, True)
+ persistent_notification.create(
+ hass, message, title=title, notification_id=notification_id
+ )
diff --git a/homeassistant/components/seventeentrack/services.yaml b/homeassistant/components/seventeentrack/services.yaml
new file mode 100644
index 00000000000..41cb66ada5f
--- /dev/null
+++ b/homeassistant/components/seventeentrack/services.yaml
@@ -0,0 +1,20 @@
+get_packages:
+ fields:
+ package_state:
+ selector:
+ select:
+ multiple: true
+ options:
+ - "not_found"
+ - "in_transit"
+ - "expired"
+ - "ready_to_be_picked_up"
+ - "undelivered"
+ - "delivered"
+ - "returned"
+ translation_key: package_state
+ config_entry_id:
+ required: true
+ selector:
+ config_entry:
+ integration: seventeentrack
diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json
index 39ddb5ef8ef..626af29e856 100644
--- a/homeassistant/components/seventeentrack/strings.json
+++ b/homeassistant/components/seventeentrack/strings.json
@@ -38,5 +38,62 @@
"title": "The 17Track YAML configuration import request failed due to invalid authentication",
"description": "Configuring 17Track using YAML is being removed but there were invalid credentials provided while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your 17Track credentials are correct and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the 17Track configuration from your YAML configuration entirely, restart Home Assistant, and add the 17Track integration manually."
}
+ },
+ "entity": {
+ "sensor": {
+ "not_found": {
+ "name": "Not found"
+ },
+ "in_transit": {
+ "name": "In transit"
+ },
+ "expired": {
+ "name": "Expired"
+ },
+ "ready_to_be_picked_up": {
+ "name": "Ready to be picked up"
+ },
+ "undelivered": {
+ "name": "Undelivered"
+ },
+ "delivered": {
+ "name": "Delivered"
+ },
+ "returned": {
+ "name": "Returned"
+ },
+ "package": {
+ "name": "Package {name}"
+ }
+ }
+ },
+ "services": {
+ "get_packages": {
+ "name": "Get packages",
+ "description": "Get packages from 17Track",
+ "fields": {
+ "package_state": {
+ "name": "Package states",
+ "description": "Only return packages with the specified states. Returns all packages if not specified."
+ },
+ "config_entry_id": {
+ "name": "17Track service",
+ "description": "The packages will be retrieved for the selected service."
+ }
+ }
+ }
+ },
+ "selector": {
+ "package_state": {
+ "options": {
+ "not_found": "[%key:component::seventeentrack::entity::sensor::not_found::name%]",
+ "in_transit": "[%key:component::seventeentrack::entity::sensor::in_transit::name%]",
+ "expired": "[%key:component::seventeentrack::entity::sensor::expired::name%]",
+ "ready_to_be_picked_up": "[%key:component::seventeentrack::entity::sensor::ready_to_be_picked_up::name%]",
+ "undelivered": "[%key:component::seventeentrack::entity::sensor::undelivered::name%]",
+ "delivered": "[%key:component::seventeentrack::entity::sensor::delivered::name%]",
+ "returned": "[%key:component::seventeentrack::entity::sensor::returned::name%]"
+ }
+ }
}
}
diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py
index b368b38820e..81289bc1a9b 100644
--- a/homeassistant/components/shelly/climate.py
+++ b/homeassistant/components/shelly/climate.py
@@ -132,7 +132,11 @@ def async_setup_rpc_entry(
climate_ids = []
for id_ in climate_key_ids:
climate_ids.append(id_)
-
+ # There are three configuration scenarios for WallDisplay:
+ # - relay mode (no thermostat)
+ # - thermostat mode using the internal relay as an actuator
+ # - thermostat mode using an external (from another device) relay as
+ # an actuator
if is_rpc_thermostat_internal_actuator(coordinator.device.status):
# Wall Display relay is used as the thermostat actuator,
# we need to remove a switch entity
diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py
index 14fec43c58b..81b16d48ab8 100644
--- a/homeassistant/components/shelly/switch.py
+++ b/homeassistant/components/shelly/switch.py
@@ -43,6 +43,7 @@ from .utils import (
is_block_channel_type_light,
is_rpc_channel_type_light,
is_rpc_thermostat_internal_actuator,
+ is_rpc_thermostat_mode,
)
@@ -140,12 +141,19 @@ def async_setup_rpc_entry(
continue
if coordinator.model == MODEL_WALL_DISPLAY:
- if not is_rpc_thermostat_internal_actuator(coordinator.device.status):
- # Wall Display relay is not used as the thermostat actuator,
- # we need to remove a climate entity
+ # There are three configuration scenarios for WallDisplay:
+ # - relay mode (no thermostat)
+ # - thermostat mode using the internal relay as an actuator
+ # - thermostat mode using an external (from another device) relay as
+ # an actuator
+ if not is_rpc_thermostat_mode(id_, coordinator.device.status):
+ # The device is not in thermostat mode, we need to remove a climate
+ # entity
unique_id = f"{coordinator.mac}-thermostat:{id_}"
async_remove_shelly_entity(hass, "climate", unique_id)
- else:
+ elif is_rpc_thermostat_internal_actuator(coordinator.device.status):
+ # The internal relay is an actuator, skip this ID so as not to create
+ # a switch entity
continue
switch_ids.append(id_)
diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py
index ce98e0d5c12..b7cb2f1476a 100644
--- a/homeassistant/components/shelly/utils.py
+++ b/homeassistant/components/shelly/utils.py
@@ -500,3 +500,8 @@ def async_remove_shelly_rpc_entities(
if entity_id := entity_reg.async_get_entity_id(domain, DOMAIN, f"{mac}-{key}"):
LOGGER.debug("Removing entity: %s", entity_id)
entity_reg.async_remove(entity_id)
+
+
+def is_rpc_thermostat_mode(ident: int, status: dict[str, Any]) -> bool:
+ """Return True if 'thermostat:' is present in the status."""
+ return f"thermostat:{ident}" in status
diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py
index 8136806cd0b..9bfa11d3293 100644
--- a/homeassistant/components/smartthings/__init__.py
+++ b/homeassistant/components/smartthings/__init__.py
@@ -28,6 +28,7 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_loaded_integration
+from homeassistant.setup import SetupPhases, async_pause_setup
from .config_flow import SmartThingsFlowHandler # noqa: F401
from .const import (
@@ -170,7 +171,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
# Setup device broker
- broker = DeviceBroker(hass, entry, token, smart_app, devices, scenes)
+ with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS):
+ # DeviceBroker has a side effect of importing platform
+ # modules when its created. In the future this should be
+ # refactored to not do this.
+ broker = await hass.async_add_import_executor_job(
+ DeviceBroker, hass, entry, token, smart_app, devices, scenes
+ )
broker.connect()
hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker
diff --git a/homeassistant/components/solaredge/__init__.py b/homeassistant/components/solaredge/__init__.py
index 69e02c1875c..64f76372e91 100644
--- a/homeassistant/components/solaredge/__init__.py
+++ b/homeassistant/components/solaredge/__init__.py
@@ -4,13 +4,14 @@ from __future__ import annotations
import socket
-from requests.exceptions import ConnectTimeout, HTTPError
-from solaredge import Solaredge
+from aiohttp import ClientError
+from aiosolaredge import SolarEdge
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN, LOGGER
@@ -22,13 +23,12 @@ PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SolarEdge from a config entry."""
- api = Solaredge(entry.data[CONF_API_KEY])
+ session = async_get_clientsession(hass)
+ api = SolarEdge(entry.data[CONF_API_KEY], session)
try:
- response = await hass.async_add_executor_job(
- api.get_details, entry.data[CONF_SITE_ID]
- )
- except (ConnectTimeout, HTTPError, socket.gaierror) as ex:
+ response = await api.get_details(entry.data[CONF_SITE_ID])
+ except (TimeoutError, ClientError, socket.gaierror) as ex:
LOGGER.error("Could not retrieve details from SolarEdge API")
raise ConfigEntryNotReady from ex
diff --git a/homeassistant/components/solaredge/config_flow.py b/homeassistant/components/solaredge/config_flow.py
index b75af866549..6235e22400f 100644
--- a/homeassistant/components/solaredge/config_flow.py
+++ b/homeassistant/components/solaredge/config_flow.py
@@ -2,15 +2,17 @@
from __future__ import annotations
+import socket
from typing import Any
-from requests.exceptions import ConnectTimeout, HTTPError
-import solaredge
+from aiohttp import ClientError
+import aiosolaredge
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import slugify
from .const import CONF_SITE_ID, DEFAULT_NAME, DOMAIN
@@ -38,15 +40,16 @@ class SolarEdgeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Return True if site_id exists in configuration."""
return site_id in self._async_current_site_ids()
- def _check_site(self, site_id: str, api_key: str) -> bool:
+ async def _async_check_site(self, site_id: str, api_key: str) -> bool:
"""Check if we can connect to the soleredge api service."""
- api = solaredge.Solaredge(api_key)
+ session = async_get_clientsession(self.hass)
+ api = aiosolaredge.SolarEdge(api_key, session)
try:
- response = api.get_details(site_id)
+ response = await api.get_details(site_id)
if response["details"]["status"].lower() != "active":
self._errors[CONF_SITE_ID] = "site_not_active"
return False
- except (ConnectTimeout, HTTPError):
+ except (TimeoutError, ClientError, socket.gaierror):
self._errors[CONF_SITE_ID] = "could_not_connect"
return False
except KeyError:
@@ -66,9 +69,7 @@ class SolarEdgeConfigFlow(ConfigFlow, domain=DOMAIN):
else:
site = user_input[CONF_SITE_ID]
api = user_input[CONF_API_KEY]
- can_connect = await self.hass.async_add_executor_job(
- self._check_site, site, api
- )
+ can_connect = await self._async_check_site(site, api)
if can_connect:
return self.async_create_entry(
title=name, data={CONF_SITE_ID: site, CONF_API_KEY: api}
diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py
index d2da99820d7..0c264c1c514 100644
--- a/homeassistant/components/solaredge/coordinator.py
+++ b/homeassistant/components/solaredge/coordinator.py
@@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
from datetime import date, datetime, timedelta
from typing import Any
-from solaredge import Solaredge
+from aiosolaredge import SolarEdge
from stringcase import snakecase
from homeassistant.core import HomeAssistant, callback
@@ -27,7 +27,7 @@ class SolarEdgeDataService(ABC):
coordinator: DataUpdateCoordinator[None]
- def __init__(self, hass: HomeAssistant, api: Solaredge, site_id: str) -> None:
+ def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None:
"""Initialize the data object."""
self.api = api
self.site_id = site_id
@@ -54,12 +54,8 @@ class SolarEdgeDataService(ABC):
"""Update interval."""
@abstractmethod
- def update(self) -> None:
- """Update data in executor."""
-
async def async_update_data(self) -> None:
"""Update data."""
- await self.hass.async_add_executor_job(self.update)
class SolarEdgeOverviewDataService(SolarEdgeDataService):
@@ -70,10 +66,10 @@ class SolarEdgeOverviewDataService(SolarEdgeDataService):
"""Update interval."""
return OVERVIEW_UPDATE_DELAY
- def update(self) -> None:
+ async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
- data = self.api.get_overview(self.site_id)
+ data = await self.api.get_overview(self.site_id)
overview = data["overview"]
except KeyError as ex:
raise UpdateFailed("Missing overview data, skipping update") from ex
@@ -113,11 +109,11 @@ class SolarEdgeDetailsDataService(SolarEdgeDataService):
"""Update interval."""
return DETAILS_UPDATE_DELAY
- def update(self) -> None:
+ async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
- data = self.api.get_details(self.site_id)
+ data = await self.api.get_details(self.site_id)
details = data["details"]
except KeyError as ex:
raise UpdateFailed("Missing details data, skipping update") from ex
@@ -157,10 +153,10 @@ class SolarEdgeInventoryDataService(SolarEdgeDataService):
"""Update interval."""
return INVENTORY_UPDATE_DELAY
- def update(self) -> None:
+ async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
- data = self.api.get_inventory(self.site_id)
+ data = await self.api.get_inventory(self.site_id)
inventory = data["Inventory"]
except KeyError as ex:
raise UpdateFailed("Missing inventory data, skipping update") from ex
@@ -178,7 +174,7 @@ class SolarEdgeInventoryDataService(SolarEdgeDataService):
class SolarEdgeEnergyDetailsService(SolarEdgeDataService):
"""Get and update the latest power flow data."""
- def __init__(self, hass: HomeAssistant, api: Solaredge, site_id: str) -> None:
+ def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None:
"""Initialize the power flow data service."""
super().__init__(hass, api, site_id)
@@ -189,17 +185,16 @@ class SolarEdgeEnergyDetailsService(SolarEdgeDataService):
"""Update interval."""
return ENERGY_DETAILS_DELAY
- def update(self) -> None:
+ async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
now = datetime.now()
today = date.today()
midnight = datetime.combine(today, datetime.min.time())
- data = self.api.get_energy_details(
+ data = await self.api.get_energy_details(
self.site_id,
midnight,
- now.strftime("%Y-%m-%d %H:%M:%S"),
- meters=None,
+ now,
time_unit="DAY",
)
energy_details = data["energyDetails"]
@@ -239,7 +234,7 @@ class SolarEdgeEnergyDetailsService(SolarEdgeDataService):
class SolarEdgePowerFlowDataService(SolarEdgeDataService):
"""Get and update the latest power flow data."""
- def __init__(self, hass: HomeAssistant, api: Solaredge, site_id: str) -> None:
+ def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None:
"""Initialize the power flow data service."""
super().__init__(hass, api, site_id)
@@ -250,10 +245,10 @@ class SolarEdgePowerFlowDataService(SolarEdgeDataService):
"""Update interval."""
return POWER_FLOW_UPDATE_DELAY
- def update(self) -> None:
+ async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
- data = self.api.get_current_power_flow(self.site_id)
+ data = await self.api.get_current_power_flow(self.site_id)
power_flow = data["siteCurrentPowerFlow"]
except KeyError as ex:
raise UpdateFailed("Missing power flow data, skipping update") from ex
diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json
index 22759b1be7c..02f96c0211f 100644
--- a/homeassistant/components/solaredge/manifest.json
+++ b/homeassistant/components/solaredge/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "solaredge",
"name": "SolarEdge",
- "codeowners": ["@frenck"],
+ "codeowners": ["@frenck", "@bdraco"],
"config_flow": true,
"dhcp": [
{
@@ -12,6 +12,6 @@
"documentation": "https://www.home-assistant.io/integrations/solaredge",
"integration_type": "device",
"iot_class": "cloud_polling",
- "loggers": ["solaredge"],
- "requirements": ["solaredge==0.0.2", "stringcase==1.2.0"]
+ "loggers": ["aiosolaredge"],
+ "requirements": ["aiosolaredge==0.2.0", "stringcase==1.2.0"]
}
diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py
index 5ec65a3b9a5..b3345d5dc86 100644
--- a/homeassistant/components/solaredge/sensor.py
+++ b/homeassistant/components/solaredge/sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
-from solaredge import Solaredge
+from aiosolaredge import SolarEdge
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -205,7 +205,7 @@ async def async_setup_entry(
) -> None:
"""Add an solarEdge entry."""
# Add the needed sensors to hass
- api: Solaredge = hass.data[DOMAIN][entry.entry_id][DATA_API_CLIENT]
+ api: SolarEdge = hass.data[DOMAIN][entry.entry_id][DATA_API_CLIENT]
sensor_factory = SolarEdgeSensorFactory(hass, entry.data[CONF_SITE_ID], api)
for service in sensor_factory.all_services:
@@ -223,7 +223,7 @@ async def async_setup_entry(
class SolarEdgeSensorFactory:
"""Factory which creates sensors based on the sensor_key."""
- def __init__(self, hass: HomeAssistant, site_id: str, api: Solaredge) -> None:
+ def __init__(self, hass: HomeAssistant, site_id: str, api: SolarEdge) -> None:
"""Initialize the factory."""
details = SolarEdgeDetailsDataService(hass, api, site_id)
diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py
index b6fc250ab23..eeadd7db232 100644
--- a/homeassistant/components/sonos/media_browser.py
+++ b/homeassistant/components/sonos/media_browser.py
@@ -199,9 +199,15 @@ def build_item_response(
payload["search_type"] == MediaType.ALBUM
and media[0].item_class == "object.item.audioItem.musicTrack"
):
- item = get_media(media_library, payload["idstring"], SONOS_ALBUM_ARTIST)
+ idstring = payload["idstring"]
+ if idstring.startswith("A:ALBUMARTIST/"):
+ search_type = SONOS_ALBUM_ARTIST
+ elif idstring.startswith("A:ALBUM/"):
+ search_type = SONOS_ALBUM
+ item = get_media(media_library, idstring, search_type)
+
title = getattr(item, "title", None)
- thumbnail = get_thumbnail_url(SONOS_ALBUM_ARTIST, payload["idstring"])
+ thumbnail = get_thumbnail_url(search_type, payload["idstring"])
if not title:
try:
@@ -493,8 +499,9 @@ def get_content_id(item: DidlObject) -> str:
def get_media(
media_library: MusicLibrary, item_id: str, search_type: str
-) -> MusicServiceItem:
- """Fetch media/album."""
+) -> MusicServiceItem | None:
+ """Fetch a single media/album."""
+ _LOGGER.debug("get_media item_id [%s], search_type [%s]", item_id, search_type)
search_type = MEDIA_TYPES_TO_SONOS.get(search_type, search_type)
if search_type == "playlists":
@@ -513,9 +520,38 @@ def get_media(
if not item_id.startswith("A:ALBUM") and search_type == SONOS_ALBUM:
item_id = "A:ALBUMARTIST/" + "/".join(item_id.split("/")[2:])
- search_term = urllib.parse.unquote(item_id.split("/")[-1])
- matches = media_library.get_music_library_information(
- search_type, search_term=search_term, full_album_art_uri=True
+ if item_id.startswith("A:ALBUM/") or search_type == "tracks":
+ search_term = urllib.parse.unquote(item_id.split("/")[-1])
+ matches = media_library.get_music_library_information(
+ search_type, search_term=search_term, full_album_art_uri=True
+ )
+ else:
+ # When requesting media by album_artist, composer, genre use the browse interface
+ # to navigate the hierarchy. This occurs when invoked from media browser or service
+ # calls
+ # Example: A:ALBUMARTIST/Neil Young/Greatest Hits - get specific album
+ # Example: A:ALBUMARTIST/Neil Young - get all albums
+ # Others: composer, genre
+ # A://
+ splits = item_id.split("/")
+ title = urllib.parse.unquote(splits[2]) if len(splits) > 2 else None
+ browse_id_string = splits[0] + "/" + splits[1]
+ matches = media_library.browse_by_idstring(
+ search_type, browse_id_string, full_album_art_uri=True
+ )
+ if title:
+ result = next(
+ (item for item in matches if (title == item.title)),
+ None,
+ )
+ matches = [result]
+
+ _LOGGER.debug(
+ "get_media search_type [%s] item_id [%s] matches [%d]",
+ search_type,
+ item_id,
+ len(matches),
)
if len(matches) > 0:
return matches[0]
+ return None
diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py
index 581bdaad37d..35c6be3fa6b 100644
--- a/homeassistant/components/sonos/media_player.py
+++ b/homeassistant/components/sonos/media_player.py
@@ -7,7 +7,7 @@ from functools import partial
import logging
from typing import Any
-from soco import alarms
+from soco import SoCo, alarms
from soco.core import (
MUSIC_SRC_LINE_IN,
MUSIC_SRC_RADIO,
@@ -15,6 +15,7 @@ from soco.core import (
PLAY_MODES,
)
from soco.data_structures import DidlFavorite
+from soco.ms_data_structures import MusicServiceItem
from sonos_websocket.exception import SonosWebsocketError
import voluptuous as vol
@@ -549,6 +550,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
self, media_type: MediaType | str, media_id: str, is_radio: bool, **kwargs: Any
) -> None:
"""Wrap sync calls to async_play_media."""
+ _LOGGER.debug("_play_media media_type %s media_id %s", media_type, media_id)
enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE)
if media_type == "favorite_item_id":
@@ -645,10 +647,35 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
_LOGGER.error('Could not find "%s" in the library', media_id)
return
- soco.play_uri(item.get_uri())
+ self._play_media_queue(soco, item, enqueue)
else:
_LOGGER.error('Sonos does not support a media type of "%s"', media_type)
+ def _play_media_queue(
+ self, soco: SoCo, item: MusicServiceItem, enqueue: MediaPlayerEnqueue
+ ):
+ """Manage adding, replacing, playing items onto the sonos queue."""
+ _LOGGER.debug(
+ "_play_media_queue item_id [%s] title [%s] enqueue [%s]",
+ item.item_id,
+ item.title,
+ enqueue,
+ )
+ if enqueue == MediaPlayerEnqueue.REPLACE:
+ soco.clear_queue()
+
+ if enqueue in (MediaPlayerEnqueue.ADD, MediaPlayerEnqueue.REPLACE):
+ soco.add_to_queue(item, timeout=LONG_SERVICE_TIMEOUT)
+ if enqueue == MediaPlayerEnqueue.REPLACE:
+ soco.play_from_queue(0)
+ else:
+ pos = (self.media.queue_position or 0) + 1
+ new_pos = soco.add_to_queue(
+ item, position=pos, timeout=LONG_SERVICE_TIMEOUT
+ )
+ if enqueue == MediaPlayerEnqueue.PLAY:
+ soco.play_from_queue(new_pos - 1)
+
@soco_error()
def set_sleep_timer(self, sleep_time: int) -> None:
"""Set the timer on the player."""
diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py
index 7d072fa2570..a3a404fe1ae 100644
--- a/homeassistant/components/squeezebox/media_player.py
+++ b/homeassistant/components/squeezebox/media_player.py
@@ -28,7 +28,6 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
- EVENT_HOMEASSISTANT_START,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
@@ -44,6 +43,7 @@ from homeassistant.helpers.dispatcher import (
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.start import async_at_start
from homeassistant.util.dt import utcnow
from .browse_media import (
@@ -207,12 +207,7 @@ async def async_setup_entry(
platform.async_register_entity_service(SERVICE_UNSYNC, None, "async_unsync")
# Start server discovery task if not already running
- if hass.is_running:
- hass.async_create_task(start_server_discovery(hass))
- else:
- hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_START, start_server_discovery(hass)
- )
+ config_entry.async_on_unload(async_at_start(hass, start_server_discovery))
class SqueezeBoxEntity(MediaPlayerEntity):
diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py
index 670d6b93c0e..956c93d01a0 100644
--- a/homeassistant/components/stream/worker.py
+++ b/homeassistant/components/stream/worker.py
@@ -592,7 +592,7 @@ def stream_worker(
except av.AVError as ex:
container.close()
raise StreamWorkerError(
- "Error demuxing stream while finding first packet: %s" % str(ex)
+ f"Error demuxing stream while finding first packet: {str(ex)}"
) from ex
muxer = StreamMuxer(
@@ -617,7 +617,7 @@ def stream_worker(
except StopIteration as ex:
raise StreamEndedError("Stream ended; no additional packets") from ex
except av.AVError as ex:
- raise StreamWorkerError("Error demuxing stream: %s" % str(ex)) from ex
+ raise StreamWorkerError(f"Error demuxing stream: {str(ex)}") from ex
muxer.mux_packet(packet)
diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py
index ec13ec929a5..2748b27c93d 100644
--- a/homeassistant/components/synology_dsm/__init__.py
+++ b/homeassistant/components/synology_dsm/__init__.py
@@ -161,6 +161,8 @@ async def async_remove_config_entry_device(
return not device_entry.identifiers.intersection(
(
(DOMAIN, serial), # Base device
- *((DOMAIN, f"{serial}_{id}") for id in device_ids), # Storage and cameras
+ *(
+ (DOMAIN, f"{serial}_{device_id}") for device_id in device_ids
+ ), # Storage and cameras
)
)
diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py
index 5ab7a6f67b8..8f69ccdaffb 100644
--- a/homeassistant/components/tado/__init__.py
+++ b/homeassistant/components/tado/__init__.py
@@ -221,7 +221,7 @@ class TadoConnector:
# Errors are planned to be converted to exceptions
# in PyTado library, so this can be removed
- if "errors" in mobile_devices and mobile_devices["errors"]:
+ if isinstance(mobile_devices, dict) and mobile_devices.get("errors"):
_LOGGER.error(
"Error for home ID %s while updating mobile devices: %s",
self.home_id,
@@ -256,7 +256,7 @@ class TadoConnector:
# Errors are planned to be converted to exceptions
# in PyTado library, so this can be removed
- if "errors" in devices and devices["errors"]:
+ if isinstance(devices, dict) and devices.get("errors"):
_LOGGER.error(
"Error for home ID %s while updating devices: %s",
self.home_id,
diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json
index 4570d0e5649..c754094655d 100644
--- a/homeassistant/components/tankerkoenig/manifest.json
+++ b/homeassistant/components/tankerkoenig/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/tankerkoenig",
"iot_class": "cloud_polling",
"loggers": ["aiotankerkoenig"],
+ "quality_scale": "platinum",
"requirements": ["aiotankerkoenig==0.4.1"]
}
diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py
index f2fdc2c45b7..33476e75262 100644
--- a/homeassistant/components/tankerkoenig/sensor.py
+++ b/homeassistant/components/tankerkoenig/sensor.py
@@ -91,7 +91,7 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity):
self._fuel_type = fuel_type
self._attr_translation_key = fuel_type
self._attr_unique_id = f"{station.id}_{fuel_type}"
- attrs = {
+ attrs: dict[str, int | str | float | None] = {
ATTR_BRAND: station.brand,
ATTR_FUEL_TYPE: fuel_type,
ATTR_STATION_NAME: station.name,
@@ -102,8 +102,8 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity):
}
if coordinator.show_on_map:
- attrs[ATTR_LATITUDE] = str(station.lat)
- attrs[ATTR_LONGITUDE] = str(station.lng)
+ attrs[ATTR_LATITUDE] = station.lat
+ attrs[ATTR_LONGITUDE] = station.lng
self._attr_extra_state_attributes = attrs
@property
diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py
index f3043b1d78d..069a7893974 100644
--- a/homeassistant/components/tedee/coordinator.py
+++ b/homeassistant/components/tedee/coordinator.py
@@ -100,9 +100,9 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]):
except TedeeDataUpdateException as ex:
_LOGGER.debug("Error while updating data: %s", str(ex))
- raise UpdateFailed("Error while updating data: %s" % str(ex)) from ex
+ raise UpdateFailed(f"Error while updating data: {str(ex)}") from ex
except (TedeeClientException, TimeoutError) as ex:
- raise UpdateFailed("Querying API failed. Error: %s" % str(ex)) from ex
+ raise UpdateFailed(f"Querying API failed. Error: {str(ex)}") from ex
def _async_add_remove_locks(self) -> None:
"""Add new locks, remove non-existing locks."""
diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py
index a720652bcbc..1c47ff2a6c1 100644
--- a/homeassistant/components/tedee/lock.py
+++ b/homeassistant/components/tedee/lock.py
@@ -90,7 +90,7 @@ class TedeeLockEntity(TedeeEntity, LockEntity):
await self.coordinator.async_request_refresh()
except (TedeeClientException, Exception) as ex:
raise HomeAssistantError(
- "Failed to unlock the door. Lock %s" % self._lock.lock_id
+ f"Failed to unlock the door. Lock {self._lock.lock_id}"
) from ex
async def async_lock(self, **kwargs: Any) -> None:
@@ -103,7 +103,7 @@ class TedeeLockEntity(TedeeEntity, LockEntity):
await self.coordinator.async_request_refresh()
except (TedeeClientException, Exception) as ex:
raise HomeAssistantError(
- "Failed to lock the door. Lock %s" % self._lock.lock_id
+ f"Failed to lock the door. Lock {self._lock.lock_id}"
) from ex
@@ -125,5 +125,5 @@ class TedeeLockWithLatchEntity(TedeeLockEntity):
await self.coordinator.async_request_refresh()
except (TedeeClientException, Exception) as ex:
raise HomeAssistantError(
- "Failed to unlatch the door. Lock %s" % self._lock.lock_id
+ f"Failed to unlatch the door. Lock {self._lock.lock_id}"
) from ex
diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py
index 897fd6a9bac..f672ae1547f 100644
--- a/homeassistant/components/telegram_bot/__init__.py
+++ b/homeassistant/components/telegram_bot/__init__.py
@@ -122,6 +122,7 @@ EVENT_TELEGRAM_SENT = "telegram_sent"
PARSER_HTML = "html"
PARSER_MD = "markdown"
PARSER_MD2 = "markdownv2"
+PARSER_PLAIN_TEXT = "plain_text"
DEFAULT_TRUSTED_NETWORKS = [ip_network("149.154.160.0/20"), ip_network("91.108.4.0/22")]
@@ -524,6 +525,7 @@ class TelegramNotificationService:
PARSER_HTML: ParseMode.HTML,
PARSER_MD: ParseMode.MARKDOWN,
PARSER_MD2: ParseMode.MARKDOWN_V2,
+ PARSER_PLAIN_TEXT: None,
}
self._parse_mode = self._parsers.get(parser)
self.bot = bot
diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml
index 1587f754508..d2195c1d6ce 100644
--- a/homeassistant/components/telegram_bot/services.yaml
+++ b/homeassistant/components/telegram_bot/services.yaml
@@ -22,6 +22,7 @@ send_message:
- "html"
- "markdown"
- "markdownv2"
+ - "plain_text"
disable_notification:
selector:
boolean:
@@ -94,6 +95,7 @@ send_photo:
- "html"
- "markdown"
- "markdownv2"
+ - "plain_text"
disable_notification:
selector:
boolean:
@@ -229,6 +231,7 @@ send_animation:
- "html"
- "markdown"
- "markdownv2"
+ - "plain_text"
disable_notification:
selector:
boolean:
@@ -300,6 +303,7 @@ send_video:
- "html"
- "markdown"
- "markdownv2"
+ - "plain_text"
disable_notification:
selector:
boolean:
@@ -435,6 +439,7 @@ send_document:
- "html"
- "markdown"
- "markdownv2"
+ - "plain_text"
disable_notification:
selector:
boolean:
@@ -587,6 +592,7 @@ edit_message:
- "html"
- "markdown"
- "markdownv2"
+ - "plain_text"
disable_web_page_preview:
selector:
boolean:
diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py
index 084d51ff31b..45fd1eee327 100644
--- a/homeassistant/components/teslemetry/__init__.py
+++ b/homeassistant/components/teslemetry/__init__.py
@@ -4,6 +4,7 @@ import asyncio
from typing import Final
from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific
+from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
InvalidToken,
SubscriptionRequired,
@@ -37,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
access_token=access_token,
)
try:
+ scopes = (await teslemetry.metadata())["scopes"]
products = (await teslemetry.products())["response"]
except InvalidToken as e:
raise ConfigEntryAuthFailed from e
@@ -49,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
vehicles: list[TeslemetryVehicleData] = []
energysites: list[TeslemetryEnergyData] = []
for product in products:
- if "vin" in product:
+ if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes:
vin = product["vin"]
api = VehicleSpecific(teslemetry.vehicle, vin)
coordinator = TeslemetryVehicleDataCoordinator(hass, api)
@@ -60,7 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
vin=vin,
)
)
- elif "energy_site_id" in product:
+ elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes:
site_id = product["energy_site_id"]
api = EnergySpecific(teslemetry.energy, site_id)
energysites.append(
@@ -86,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Setup Platforms
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = TeslemetryData(
- vehicles, energysites
+ vehicles, energysites, scopes
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py
index 0835785d194..4c1c05570ab 100644
--- a/homeassistant/components/teslemetry/climate.py
+++ b/homeassistant/components/teslemetry/climate.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
+from tesla_fleet_api.const import Scope
+
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
@@ -17,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, TeslemetryClimateSide
from .context import handle_command
from .entity import TeslemetryVehicleEntity
+from .models import TeslemetryVehicleData
async def async_setup_entry(
@@ -26,7 +29,7 @@ async def async_setup_entry(
data = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
- TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER)
+ TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER, data.scopes)
for vehicle in data.vehicles
)
@@ -48,6 +51,22 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
_attr_preset_modes = ["off", "keep", "dog", "camp"]
_enable_turn_on_off_backwards_compatibility = False
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ side: TeslemetryClimateSide,
+ scopes: Scope,
+ ) -> None:
+ """Initialize the climate."""
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
+ self._attr_supported_features = ClimateEntityFeature(0)
+
+ super().__init__(
+ data,
+ side,
+ )
+
@property
def hvac_mode(self) -> HVACMode | None:
"""Return hvac operation ie. heat, cool mode."""
@@ -82,6 +101,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
async def async_turn_on(self) -> None:
"""Set the climate state to on."""
+ self.raise_for_scope()
with handle_command():
await self.wake_up_if_asleep()
await self.api.auto_conditioning_start()
@@ -89,6 +109,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
async def async_turn_off(self) -> None:
"""Set the climate state to off."""
+ self.raise_for_scope()
with handle_command():
await self.wake_up_if_asleep()
await self.api.auto_conditioning_stop()
diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py
index eda3d26f341..d67a1bd1770 100644
--- a/homeassistant/components/teslemetry/entity.py
+++ b/homeassistant/components/teslemetry/entity.py
@@ -5,7 +5,7 @@ from typing import Any
from tesla_fleet_api.exceptions import TeslaFleetError
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -83,6 +83,11 @@ class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator
self.coordinator.data[key] = value
self.async_write_ha_state()
+ def raise_for_scope(self):
+ """Raise an error if a scope is not available."""
+ if not self.scoped:
+ raise ServiceValidationError("Missing required scope")
+
class TeslemetryEnergyEntity(CoordinatorEntity[TeslemetryEnergyDataCoordinator]):
"""Parent class for Teslemetry Energy Entities."""
diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py
index d6f15e2e932..615156e6fdc 100644
--- a/homeassistant/components/teslemetry/models.py
+++ b/homeassistant/components/teslemetry/models.py
@@ -6,6 +6,7 @@ import asyncio
from dataclasses import dataclass
from tesla_fleet_api import EnergySpecific, VehicleSpecific
+from tesla_fleet_api.const import Scope
from .coordinator import (
TeslemetryEnergyDataCoordinator,
@@ -19,6 +20,7 @@ class TeslemetryData:
vehicles: list[TeslemetryVehicleData]
energysites: list[TeslemetryEnergyData]
+ scopes: list[Scope]
@dataclass
diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py
index cced1090e2a..6380a4d0c71 100644
--- a/homeassistant/components/teslemetry/sensor.py
+++ b/homeassistant/components/teslemetry/sensor.py
@@ -58,7 +58,7 @@ SHIFT_STATES = {"P": "p", "D": "d", "R": "r", "N": "n"}
class TeslemetrySensorEntityDescription(SensorEntityDescription):
"""Describes Teslemetry Sensor entity."""
- value_fn: Callable[[StateType], StateType | datetime] = lambda x: x
+ value_fn: Callable[[StateType], StateType] = lambda x: x
VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
@@ -447,12 +447,13 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity):
description: TeslemetrySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
+ self.entity_description = description
super().__init__(vehicle, description.key)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
- return self._value
+ return self.entity_description.value_fn(self._value)
class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity):
diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py
index 09402055ee8..1e5653744fb 100644
--- a/homeassistant/components/tessie/lock.py
+++ b/homeassistant/components/tessie/lock.py
@@ -12,10 +12,14 @@ from tessie_api import (
unlock,
)
+from homeassistant.components.automation import automations_with_entity
from homeassistant.components.lock import ATTR_CODE, LockEntity
+from homeassistant.components.script import scripts_with_entity
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, TessieChargeCableLockStates
@@ -29,11 +33,46 @@ async def async_setup_entry(
"""Set up the Tessie sensor platform from a config entry."""
data = hass.data[DOMAIN][entry.entry_id]
- async_add_entities(
+ entities = [
klass(vehicle.state_coordinator)
- for klass in (TessieLockEntity, TessieCableLockEntity, TessieSpeedLimitEntity)
+ for klass in (TessieLockEntity, TessieCableLockEntity)
for vehicle in data
- )
+ ]
+
+ ent_reg = er.async_get(hass)
+
+ for vehicle in data:
+ entity_id = ent_reg.async_get_entity_id(
+ Platform.LOCK,
+ DOMAIN,
+ f"{vehicle.state_coordinator.vin}-vehicle_state_speed_limit_mode_active",
+ )
+ if entity_id:
+ entity_entry = ent_reg.async_get(entity_id)
+ assert entity_entry
+ if entity_entry.disabled:
+ ent_reg.async_remove(entity_id)
+ else:
+ entities.append(TessieSpeedLimitEntity(vehicle.state_coordinator))
+
+ entity_automations = automations_with_entity(hass, entity_id)
+ entity_scripts = scripts_with_entity(hass, entity_id)
+ for item in entity_automations + entity_scripts:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ f"deprecated_speed_limit_{entity_id}_{item}",
+ breaks_in_ha_version="2024.11.0",
+ is_fixable=True,
+ is_persistent=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_speed_limit_entity",
+ translation_placeholders={
+ "entity": entity_id,
+ "info": item,
+ },
+ )
+ async_add_entities(entities)
class TessieLockEntity(TessieEntity, LockEntity):
@@ -81,6 +120,16 @@ class TessieSpeedLimitEntity(TessieEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None:
"""Enable speed limit with pin."""
+ ir.async_create_issue(
+ self.coordinator.hass,
+ DOMAIN,
+ "deprecated_speed_limit_locked",
+ breaks_in_ha_version="2024.11.0",
+ is_fixable=True,
+ is_persistent=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_speed_limit_locked",
+ )
code: str | None = kwargs.get(ATTR_CODE)
if code:
await self.run(enable_speed_limit, pin=code)
@@ -88,6 +137,16 @@ class TessieSpeedLimitEntity(TessieEntity, LockEntity):
async def async_unlock(self, **kwargs: Any) -> None:
"""Disable speed limit with pin."""
+ ir.async_create_issue(
+ self.coordinator.hass,
+ DOMAIN,
+ "deprecated_speed_limit_unlocked",
+ breaks_in_ha_version="2024.11.0",
+ is_fixable=True,
+ is_persistent=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_speed_limit_unlocked",
+ )
code: str | None = kwargs.get(ATTR_CODE)
if code:
await self.run(disable_speed_limit, pin=code)
diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json
index 8e1e47f934f..ea75660ddb7 100644
--- a/homeassistant/components/tessie/strings.json
+++ b/homeassistant/components/tessie/strings.json
@@ -410,5 +410,40 @@
"no_cable": {
"message": "Insert cable to lock"
}
+ },
+ "issues": {
+ "deprecated_speed_limit_entity": {
+ "title": "Detected Tessie speed limit lock entity usage",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::tessie::issues::deprecated_speed_limit_entity::title%]",
+ "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\nHome Assistant detected that entity `{entity}` is being used in `{info}`\n\nYou should remove the speed limit lock entity from `{info}` then click submit to fix this issue."
+ }
+ }
+ }
+ },
+ "deprecated_speed_limit_locked": {
+ "title": "Detected Tessie speed limit lock entity locked",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::tessie::issues::deprecated_speed_limit_locked::title%]",
+ "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\n\nPlease remove this entity from any automation or script, disable the entity then click submit to fix this issue."
+ }
+ }
+ }
+ },
+ "deprecated_speed_limit_unlocked": {
+ "title": "Detected Tessie speed limit lock entity unlocked",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::tessie::issues::deprecated_speed_limit_unlocked::title%]",
+ "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\n\nPlease remove this entity from any automation or script, disable the entity then click submit to fix this issue."
+ }
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py
index da2fd881a54..7da0a2b7947 100644
--- a/homeassistant/components/tibber/sensor.py
+++ b/homeassistant/components/tibber/sensor.py
@@ -53,6 +53,8 @@ from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN as TIBBER_DOMAIN, MANUFACTURER
+FIVE_YEARS = 5 * 365 * 24
+
_LOGGER = logging.getLogger(__name__)
ICON = "mdi:currency-usd"
@@ -724,9 +726,16 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): # pylint: disable=has
None,
{"sum"},
)
- first_stat = stat[statistic_id][0]
- _sum = cast(float, first_stat["sum"])
- last_stats_time = first_stat["start"]
+ if statistic_id in stat:
+ first_stat = stat[statistic_id][0]
+ _sum = cast(float, first_stat["sum"])
+ last_stats_time = first_stat["start"]
+ else:
+ hourly_data = await home.get_historic_data(
+ FIVE_YEARS, production=is_production
+ )
+ _sum = 0.0
+ last_stats_time = None
statistics = []
diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py
index 436e3198650..1de9db1d319 100644
--- a/homeassistant/components/totalconnect/alarm_control_panel.py
+++ b/homeassistant/components/totalconnect/alarm_control_panel.py
@@ -4,9 +4,12 @@ from __future__ import annotations
from total_connect_client import ArmingHelper
from total_connect_client.exceptions import BadResultCodeError, UsercodeInvalid
+from total_connect_client.location import TotalConnectLocation
-import homeassistant.components.alarm_control_panel as alarm
-from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature
+from homeassistant.components.alarm_control_panel import (
+ AlarmControlPanelEntity,
+ AlarmControlPanelEntityFeature,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
STATE_ALARM_ARMED_AWAY,
@@ -21,12 +24,11 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import TotalConnectDataUpdateCoordinator
from .const import DOMAIN
+from .entity import TotalConnectLocationEntity
SERVICE_ALARM_ARM_AWAY_INSTANT = "arm_away_instant"
SERVICE_ALARM_ARM_HOME_INSTANT = "arm_home_instant"
@@ -36,23 +38,17 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up TotalConnect alarm panels based on a config entry."""
- alarms: list[TotalConnectAlarm] = []
-
coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
- for location_id, location in coordinator.client.locations.items():
- location_name = location.location_name
- alarms.extend(
- TotalConnectAlarm(
- coordinator=coordinator,
- name=location_name,
- location_id=location_id,
- partition_id=partition_id,
- )
- for partition_id in location.partitions
+ async_add_entities(
+ TotalConnectAlarm(
+ coordinator,
+ location,
+ partition_id,
)
-
- async_add_entities(alarms)
+ for location in coordinator.client.locations.values()
+ for partition_id in location.partitions
+ )
# Set up services
platform = entity_platform.async_get_current_platform()
@@ -70,10 +66,8 @@ async def async_setup_entry(
)
-class TotalConnectAlarm(
- CoordinatorEntity[TotalConnectDataUpdateCoordinator], alarm.AlarmControlPanelEntity
-):
- """Represent an TotalConnect status."""
+class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity):
+ """Represent a TotalConnect alarm panel."""
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_HOME
@@ -84,19 +78,13 @@ class TotalConnectAlarm(
def __init__(
self,
coordinator: TotalConnectDataUpdateCoordinator,
- name,
- location_id,
- partition_id,
+ location: TotalConnectLocation,
+ partition_id: int,
) -> None:
"""Initialize the TotalConnect status."""
- super().__init__(coordinator)
- self._location_id = location_id
- self._location = coordinator.client.locations[location_id]
+ super().__init__(coordinator, location)
self._partition_id = partition_id
self._partition = self._location.partitions[partition_id]
- self._device = self._location.devices[self._location.security_device_id]
- self._state: str | None = None
- self._attr_extra_state_attributes = {}
"""
Set unique_id to location_id for partition 1 to avoid breaking change
@@ -104,27 +92,18 @@ class TotalConnectAlarm(
Add _# for partition 2 and beyond.
"""
if partition_id == 1:
- self._attr_name = name
- self._attr_unique_id = f"{location_id}"
+ self._attr_name = None
+ self._attr_unique_id = str(location.location_id)
else:
- self._attr_name = f"{name} partition {partition_id}"
- self._attr_unique_id = f"{location_id}_{partition_id}"
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return device info."""
- return DeviceInfo(
- identifiers={(DOMAIN, self._device.serial_number)},
- name=self._device.name,
- serial_number=self._device.serial_number,
- )
+ self._attr_translation_key = "partition"
+ self._attr_translation_placeholders = {"partition_id": str(partition_id)}
+ self._attr_unique_id = f"{location.location_id}_{partition_id}"
@property
def state(self) -> str | None:
"""Return the state of the device."""
attr = {
- "location_name": self.name,
- "location_id": self._location_id,
+ "location_id": self._location.location_id,
"partition": self._partition_id,
"ac_loss": self._location.ac_loss,
"low_battery": self._location.low_battery,
@@ -133,6 +112,11 @@ class TotalConnectAlarm(
"triggered_zone": None,
}
+ if self._partition_id == 1:
+ attr["location_name"] = self.device.name
+ else:
+ attr["location_name"] = f"{self.device.name} partition {self._partition_id}"
+
state: str | None = None
if self._partition.arming_state.is_disarmed():
state = STATE_ALARM_DISARMED
@@ -158,10 +142,9 @@ class TotalConnectAlarm(
state = STATE_ALARM_TRIGGERED
attr["triggered_source"] = "Carbon Monoxide"
- self._state = state
self._attr_extra_state_attributes = attr
- return self._state
+ return state
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
@@ -174,7 +157,7 @@ class TotalConnectAlarm(
) from error
except BadResultCodeError as error:
raise HomeAssistantError(
- f"TotalConnect failed to disarm {self.name}."
+ f"TotalConnect failed to disarm {self.device.name}."
) from error
await self.coordinator.async_request_refresh()
@@ -193,7 +176,7 @@ class TotalConnectAlarm(
) from error
except BadResultCodeError as error:
raise HomeAssistantError(
- f"TotalConnect failed to arm home {self.name}."
+ f"TotalConnect failed to arm home {self.device.name}."
) from error
await self.coordinator.async_request_refresh()
@@ -212,7 +195,7 @@ class TotalConnectAlarm(
) from error
except BadResultCodeError as error:
raise HomeAssistantError(
- f"TotalConnect failed to arm away {self.name}."
+ f"TotalConnect failed to arm away {self.device.name}."
) from error
await self.coordinator.async_request_refresh()
@@ -231,7 +214,7 @@ class TotalConnectAlarm(
) from error
except BadResultCodeError as error:
raise HomeAssistantError(
- f"TotalConnect failed to arm night {self.name}."
+ f"TotalConnect failed to arm night {self.device.name}."
) from error
await self.coordinator.async_request_refresh()
@@ -250,7 +233,7 @@ class TotalConnectAlarm(
) from error
except BadResultCodeError as error:
raise HomeAssistantError(
- f"TotalConnect failed to arm home instant {self.name}."
+ f"TotalConnect failed to arm home instant {self.device.name}."
) from error
await self.coordinator.async_request_refresh()
@@ -269,7 +252,7 @@ class TotalConnectAlarm(
) from error
except BadResultCodeError as error:
raise HomeAssistantError(
- f"TotalConnect failed to arm away instant {self.name}."
+ f"TotalConnect failed to arm away instant {self.device.name}."
) from error
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py
index 6043d15d2d4..85461805124 100644
--- a/homeassistant/components/totalconnect/binary_sensor.py
+++ b/homeassistant/components/totalconnect/binary_sensor.py
@@ -1,7 +1,12 @@
"""Interfaces with TotalConnect sensors."""
+from collections.abc import Callable
+from dataclasses import dataclass
import logging
+from total_connect_client.location import TotalConnectLocation
+from total_connect_client.zone import TotalConnectZone
+
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -10,10 +15,11 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import TotalConnectDataUpdateCoordinator
from .const import DOMAIN
+from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity
LOW_BATTERY = "low_battery"
TAMPER = "tamper"
@@ -23,172 +29,172 @@ ZONE = "zone"
_LOGGER = logging.getLogger(__name__)
+@dataclass(frozen=True, kw_only=True)
+class TotalConnectZoneBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Describes TotalConnect binary sensor entity."""
+
+ device_class_fn: Callable[[TotalConnectZone], BinarySensorDeviceClass] | None = None
+ is_on_fn: Callable[[TotalConnectZone], bool]
+
+
+def get_security_zone_device_class(zone: TotalConnectZone) -> BinarySensorDeviceClass:
+ """Return the device class of a TotalConnect security zone."""
+ if zone.is_type_fire():
+ return BinarySensorDeviceClass.SMOKE
+ if zone.is_type_carbon_monoxide():
+ return BinarySensorDeviceClass.GAS
+ if zone.is_type_motion():
+ return BinarySensorDeviceClass.MOTION
+ if zone.is_type_medical():
+ return BinarySensorDeviceClass.SAFETY
+ if zone.is_type_temperature():
+ return BinarySensorDeviceClass.PROBLEM
+ return BinarySensorDeviceClass.DOOR
+
+
+SECURITY_BINARY_SENSOR = TotalConnectZoneBinarySensorEntityDescription(
+ key=ZONE,
+ name=None,
+ device_class_fn=get_security_zone_device_class,
+ is_on_fn=lambda zone: zone.is_faulted() or zone.is_triggered(),
+)
+
+NO_BUTTON_BINARY_SENSORS: tuple[TotalConnectZoneBinarySensorEntityDescription, ...] = (
+ TotalConnectZoneBinarySensorEntityDescription(
+ key=LOW_BATTERY,
+ device_class=BinarySensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on_fn=lambda zone: zone.is_low_battery(),
+ ),
+ TotalConnectZoneBinarySensorEntityDescription(
+ key=TAMPER,
+ device_class=BinarySensorDeviceClass.TAMPER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on_fn=lambda zone: zone.is_tampered(),
+ ),
+)
+
+
+@dataclass(frozen=True, kw_only=True)
+class TotalConnectAlarmBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Describes TotalConnect binary sensor entity."""
+
+ is_on_fn: Callable[[TotalConnectLocation], bool]
+
+
+LOCATION_BINARY_SENSORS: tuple[TotalConnectAlarmBinarySensorEntityDescription, ...] = (
+ TotalConnectAlarmBinarySensorEntityDescription(
+ key=LOW_BATTERY,
+ device_class=BinarySensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on_fn=lambda location: location.is_low_battery(),
+ ),
+ TotalConnectAlarmBinarySensorEntityDescription(
+ key=TAMPER,
+ device_class=BinarySensorDeviceClass.TAMPER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on_fn=lambda location: location.is_cover_tampered(),
+ ),
+ TotalConnectAlarmBinarySensorEntityDescription(
+ key=POWER,
+ device_class=BinarySensorDeviceClass.POWER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on_fn=lambda location: location.is_ac_loss(),
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up TotalConnect device sensors based on a config entry."""
sensors: list = []
- client_locations = hass.data[DOMAIN][entry.entry_id].client.locations
+ coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+
+ client_locations = coordinator.client.locations
for location_id, location in client_locations.items():
- sensors.append(TotalConnectAlarmLowBatteryBinarySensor(location))
- sensors.append(TotalConnectAlarmTamperBinarySensor(location))
- sensors.append(TotalConnectAlarmPowerBinarySensor(location))
+ sensors.extend(
+ TotalConnectAlarmBinarySensor(coordinator, description, location)
+ for description in LOCATION_BINARY_SENSORS
+ )
for zone in location.zones.values():
- sensors.append(TotalConnectZoneSecurityBinarySensor(location_id, zone))
+ sensors.append(
+ TotalConnectZoneBinarySensor(
+ coordinator, SECURITY_BINARY_SENSOR, zone, location_id
+ )
+ )
if not zone.is_type_button():
- sensors.append(TotalConnectLowBatteryBinarySensor(location_id, zone))
- sensors.append(TotalConnectTamperBinarySensor(location_id, zone))
+ sensors.extend(
+ TotalConnectZoneBinarySensor(
+ coordinator,
+ description,
+ zone,
+ location_id,
+ )
+ for description in NO_BUTTON_BINARY_SENSORS
+ )
- async_add_entities(sensors, True)
+ async_add_entities(sensors)
-class TotalConnectZoneBinarySensor(BinarySensorEntity):
- """Represent an TotalConnect zone."""
+class TotalConnectZoneBinarySensor(TotalConnectZoneEntity, BinarySensorEntity):
+ """Represent a TotalConnect zone."""
- def __init__(self, location_id, zone):
+ entity_description: TotalConnectZoneBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: TotalConnectDataUpdateCoordinator,
+ entity_description: TotalConnectZoneBinarySensorEntityDescription,
+ zone: TotalConnectZone,
+ location_id: str,
+ ) -> None:
"""Initialize the TotalConnect status."""
- self._location_id = location_id
- self._zone = zone
- self._attr_name = f"{zone.description}{self.entity_description.name}"
- self._attr_unique_id = (
- f"{location_id}_{zone.zoneid}_{self.entity_description.key}"
- )
- self._attr_is_on = None
+ super().__init__(coordinator, zone, location_id, entity_description.key)
+ self.entity_description = entity_description
self._attr_extra_state_attributes = {
- "zone_id": self._zone.zoneid,
- "location_id": self._location_id,
- "partition": self._zone.partition,
+ "zone_id": zone.zoneid,
+ "location_id": location_id,
+ "partition": zone.partition,
}
@property
- def device_info(self) -> DeviceInfo:
- """Return device info."""
- identifier = self._zone.sensor_serial_number or f"zone_{self._zone.zoneid}"
- return DeviceInfo(
- name=self._zone.description,
- identifiers={(DOMAIN, identifier)},
- serial_number=self._zone.sensor_serial_number,
- )
-
-
-class TotalConnectZoneSecurityBinarySensor(TotalConnectZoneBinarySensor):
- """Represent an TotalConnect security zone."""
-
- entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
- key=ZONE, name=""
- )
+ def is_on(self) -> bool:
+ """Return the state of the entity."""
+ return self.entity_description.is_on_fn(self._zone)
@property
- def device_class(self):
+ def device_class(self) -> BinarySensorDeviceClass | None:
"""Return the class of this zone."""
- if self._zone.is_type_fire():
- return BinarySensorDeviceClass.SMOKE
- if self._zone.is_type_carbon_monoxide():
- return BinarySensorDeviceClass.GAS
- if self._zone.is_type_motion():
- return BinarySensorDeviceClass.MOTION
- if self._zone.is_type_medical():
- return BinarySensorDeviceClass.SAFETY
- if self._zone.is_type_temperature():
- return BinarySensorDeviceClass.PROBLEM
- return BinarySensorDeviceClass.DOOR
-
- def update(self):
- """Return the state of the device."""
- if self._zone.is_faulted() or self._zone.is_triggered():
- self._attr_is_on = True
- else:
- self._attr_is_on = False
+ if self.entity_description.device_class_fn:
+ return self.entity_description.device_class_fn(self._zone)
+ return super().device_class
-class TotalConnectLowBatteryBinarySensor(TotalConnectZoneBinarySensor):
- """Represent an TotalConnect zone low battery status."""
+class TotalConnectAlarmBinarySensor(TotalConnectLocationEntity, BinarySensorEntity):
+ """Represent a TotalConnect alarm device binary sensors."""
- entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
- key=LOW_BATTERY,
- device_class=BinarySensorDeviceClass.BATTERY,
- entity_category=EntityCategory.DIAGNOSTIC,
- name=" low battery",
- )
+ entity_description: TotalConnectAlarmBinarySensorEntityDescription
- def update(self):
- """Return the state of the device."""
- self._attr_is_on = self._zone.is_low_battery()
-
-
-class TotalConnectTamperBinarySensor(TotalConnectZoneBinarySensor):
- """Represent an TotalConnect zone tamper status."""
-
- entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
- key=TAMPER,
- device_class=BinarySensorDeviceClass.TAMPER,
- entity_category=EntityCategory.DIAGNOSTIC,
- name=f" {TAMPER}",
- )
-
- def update(self):
- """Return the state of the device."""
- self._attr_is_on = self._zone.is_tampered()
-
-
-class TotalConnectAlarmBinarySensor(BinarySensorEntity):
- """Represent an TotalConnect alarm device binary sensors."""
-
- def __init__(self, location):
+ def __init__(
+ self,
+ coordinator: TotalConnectDataUpdateCoordinator,
+ entity_description: TotalConnectAlarmBinarySensorEntityDescription,
+ location: TotalConnectLocation,
+ ) -> None:
"""Initialize the TotalConnect alarm device binary sensor."""
- self._location = location
- self._attr_name = f"{location.location_name}{self.entity_description.name}"
- self._attr_unique_id = f"{location.location_id}_{self.entity_description.key}"
- self._attr_is_on = None
+ super().__init__(coordinator, location)
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{location.location_id}_{entity_description.key}"
self._attr_extra_state_attributes = {
- "location_id": self._location.location_id,
+ "location_id": location.location_id,
}
-
-class TotalConnectAlarmLowBatteryBinarySensor(TotalConnectAlarmBinarySensor):
- """Represent an TotalConnect Alarm low battery status."""
-
- entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
- key=LOW_BATTERY,
- device_class=BinarySensorDeviceClass.BATTERY,
- entity_category=EntityCategory.DIAGNOSTIC,
- name=" low battery",
- )
-
- def update(self):
- """Return the state of the device."""
- self._attr_is_on = self._location.is_low_battery()
-
-
-class TotalConnectAlarmTamperBinarySensor(TotalConnectAlarmBinarySensor):
- """Represent an TotalConnect alarm tamper status."""
-
- entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
- key=TAMPER,
- device_class=BinarySensorDeviceClass.TAMPER,
- entity_category=EntityCategory.DIAGNOSTIC,
- name=f" {TAMPER}",
- )
-
- def update(self):
- """Return the state of the device."""
- self._attr_is_on = self._location.is_cover_tampered()
-
-
-class TotalConnectAlarmPowerBinarySensor(TotalConnectAlarmBinarySensor):
- """Represent an TotalConnect alarm power status."""
-
- entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
- key=POWER,
- device_class=BinarySensorDeviceClass.POWER,
- entity_category=EntityCategory.DIAGNOSTIC,
- name=f" {POWER}",
- )
-
- def update(self):
- """Return the state of the device."""
- self._attr_is_on = not self._location.is_ac_loss()
+ @property
+ def is_on(self) -> bool:
+ """Return the state of the entity."""
+ return self.entity_description.is_on_fn(self._location)
diff --git a/homeassistant/components/totalconnect/entity.py b/homeassistant/components/totalconnect/entity.py
new file mode 100644
index 00000000000..a18ffc14df5
--- /dev/null
+++ b/homeassistant/components/totalconnect/entity.py
@@ -0,0 +1,57 @@
+"""Base class for TotalConnect entities."""
+
+from total_connect_client.location import TotalConnectLocation
+from total_connect_client.zone import TotalConnectZone
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import DOMAIN, TotalConnectDataUpdateCoordinator
+
+
+class TotalConnectEntity(CoordinatorEntity[TotalConnectDataUpdateCoordinator]):
+ """Represent a TotalConnect entity."""
+
+ _attr_has_entity_name = True
+
+
+class TotalConnectLocationEntity(TotalConnectEntity):
+ """Represent a TotalConnect location."""
+
+ def __init__(
+ self,
+ coordinator: TotalConnectDataUpdateCoordinator,
+ location: TotalConnectLocation,
+ ) -> None:
+ """Initialize the TotalConnect location."""
+ super().__init__(coordinator)
+ self._location = location
+ self.device = device = location.devices[location.security_device_id]
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.serial_number)},
+ name=device.name,
+ serial_number=device.serial_number,
+ )
+
+
+class TotalConnectZoneEntity(TotalConnectEntity):
+ """Represent a TotalConnect zone."""
+
+ def __init__(
+ self,
+ coordinator: TotalConnectDataUpdateCoordinator,
+ zone: TotalConnectZone,
+ location_id: str,
+ key: str,
+ ) -> None:
+ """Initialize the TotalConnect zone."""
+ super().__init__(coordinator)
+ self._location_id = location_id
+ self._zone = zone
+ self._attr_unique_id = f"{location_id}_{zone.zoneid}_{key}"
+ identifier = zone.sensor_serial_number or f"zone_{zone.zoneid}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, identifier)},
+ name=zone.description,
+ serial_number=zone.sensor_serial_number,
+ )
diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json
index 183919f05f2..d1afb01210d 100644
--- a/homeassistant/components/totalconnect/manifest.json
+++ b/homeassistant/components/totalconnect/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/totalconnect",
"iot_class": "cloud_polling",
"loggers": ["total_connect_client"],
- "requirements": ["total-connect-client==2023.2"]
+ "requirements": ["total-connect-client==2023.12.1"]
}
diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json
index 922962c9866..03656b60084 100644
--- a/homeassistant/components/totalconnect/strings.json
+++ b/homeassistant/components/totalconnect/strings.json
@@ -49,5 +49,12 @@
"name": "Arm home instant",
"description": "Arms Home with zero entry delay."
}
+ },
+ "entity": {
+ "alarm_control_panel": {
+ "partition": {
+ "name": "Partition {partition_id}"
+ }
+ }
}
}
diff --git a/homeassistant/components/traccar_server/__init__.py b/homeassistant/components/traccar_server/__init__.py
index 703df6cbfa4..c7a65d2d4a8 100644
--- a/homeassistant/components/traccar_server/__init__.py
+++ b/homeassistant/components/traccar_server/__init__.py
@@ -30,7 +30,11 @@ from .const import (
)
from .coordinator import TraccarServerCoordinator
-PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER, Platform.SENSOR]
+PLATFORMS: list[Platform] = [
+ Platform.BINARY_SENSOR,
+ Platform.DEVICE_TRACKER,
+ Platform.SENSOR,
+]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/traccar_server/binary_sensor.py b/homeassistant/components/traccar_server/binary_sensor.py
new file mode 100644
index 00000000000..6ee5757dcea
--- /dev/null
+++ b/homeassistant/components/traccar_server/binary_sensor.py
@@ -0,0 +1,99 @@
+"""Support for Traccar server binary sensors."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Generic, Literal, TypeVar, cast
+
+from pytraccar import DeviceModel
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import TraccarServerCoordinator
+from .entity import TraccarServerEntity
+
+_T = TypeVar("_T")
+
+
+@dataclass(frozen=True, kw_only=True)
+class TraccarServerBinarySensorEntityDescription(
+ Generic[_T], BinarySensorEntityDescription
+):
+ """Describe Traccar Server sensor entity."""
+
+ data_key: Literal["position", "device", "geofence", "attributes"]
+ entity_registry_enabled_default = False
+ entity_category = EntityCategory.DIAGNOSTIC
+ value_fn: Callable[[_T], bool | None]
+
+
+TRACCAR_SERVER_BINARY_SENSOR_ENTITY_DESCRIPTIONS = (
+ TraccarServerBinarySensorEntityDescription[DeviceModel](
+ key="attributes.motion",
+ data_key="position",
+ translation_key="motion",
+ device_class=BinarySensorDeviceClass.MOTION,
+ value_fn=lambda x: x["attributes"].get("motion", False),
+ ),
+ TraccarServerBinarySensorEntityDescription[DeviceModel](
+ key="status",
+ data_key="device",
+ translation_key="status",
+ value_fn=lambda x: None if (s := x["status"]) == "unknown" else s == "online",
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up binary sensor entities."""
+ coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id]
+ async_add_entities(
+ TraccarServerBinarySensor(
+ coordinator=coordinator,
+ device=entry["device"],
+ description=cast(TraccarServerBinarySensorEntityDescription, description),
+ )
+ for entry in coordinator.data.values()
+ for description in TRACCAR_SERVER_BINARY_SENSOR_ENTITY_DESCRIPTIONS
+ )
+
+
+class TraccarServerBinarySensor(TraccarServerEntity, BinarySensorEntity):
+ """Represent a traccar server binary sensor."""
+
+ _attr_has_entity_name = True
+ entity_description: TraccarServerBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: TraccarServerCoordinator,
+ device: DeviceModel,
+ description: TraccarServerBinarySensorEntityDescription[_T],
+ ) -> None:
+ """Initialize the Traccar Server sensor."""
+ super().__init__(coordinator, device)
+ self.entity_description = description
+ self._attr_unique_id = (
+ f"{device['uniqueId']}_{description.data_key}_{description.key}"
+ )
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return if the binary sensor is on or not."""
+ return self.entity_description.value_fn(
+ getattr(self, f"traccar_{self.entity_description.data_key}")
+ )
diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py
index d15ba084dad..e7dba3ad99d 100644
--- a/homeassistant/components/traccar_server/device_tracker.py
+++ b/homeassistant/components/traccar_server/device_tracker.py
@@ -9,14 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import (
- ATTR_CATEGORY,
- ATTR_MOTION,
- ATTR_STATUS,
- ATTR_TRACCAR_ID,
- ATTR_TRACKER,
- DOMAIN,
-)
+from .const import ATTR_CATEGORY, ATTR_TRACCAR_ID, ATTR_TRACKER, DOMAIN
from .coordinator import TraccarServerCoordinator
from .entity import TraccarServerEntity
@@ -46,8 +39,6 @@ class TraccarServerDeviceTracker(TraccarServerEntity, TrackerEntity):
return {
**self.traccar_attributes,
ATTR_CATEGORY: self.traccar_device["category"],
- ATTR_MOTION: self.traccar_position["attributes"].get("motion", False),
- ATTR_STATUS: self.traccar_device["status"],
ATTR_TRACCAR_ID: self.traccar_device["id"],
ATTR_TRACKER: DOMAIN,
}
diff --git a/homeassistant/components/traccar_server/diagnostics.py b/homeassistant/components/traccar_server/diagnostics.py
index 80dc7a9c7cd..68f1e4fca8a 100644
--- a/homeassistant/components/traccar_server/diagnostics.py
+++ b/homeassistant/components/traccar_server/diagnostics.py
@@ -57,7 +57,7 @@ async def async_get_config_entry_diagnostics(
"coordinator_data": coordinator.data,
"entities": [
{
- "enity_id": entity.entity_id,
+ "entity_id": entity.entity_id,
"disabled": entity.disabled,
"unit_of_measurement": entity.unit_of_measurement,
"state": _entity_state(hass, entity, coordinator),
@@ -92,7 +92,7 @@ async def async_get_device_diagnostics(
"coordinator_data": coordinator.data,
"entities": [
{
- "enity_id": entity.entity_id,
+ "entity_id": entity.entity_id,
"disabled": entity.disabled,
"unit_of_measurement": entity.unit_of_measurement,
"state": _entity_state(hass, entity, coordinator),
diff --git a/homeassistant/components/traccar_server/icons.json b/homeassistant/components/traccar_server/icons.json
index 59fc663e712..a10b154fbff 100644
--- a/homeassistant/components/traccar_server/icons.json
+++ b/homeassistant/components/traccar_server/icons.json
@@ -1,5 +1,14 @@
{
"entity": {
+ "binary_sensor": {
+ "status": {
+ "default": "mdi:access-point-minus",
+ "state": {
+ "off": "mdi:access-point-off",
+ "on": "mdi:access-point"
+ }
+ }
+ },
"sensor": {
"altitude": {
"default": "mdi:altimeter"
diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json
index 41adaace77e..8bec4b112ac 100644
--- a/homeassistant/components/traccar_server/strings.json
+++ b/homeassistant/components/traccar_server/strings.json
@@ -43,6 +43,22 @@
}
},
"entity": {
+ "binary_sensor": {
+ "motion": {
+ "name": "Motion",
+ "state": {
+ "off": "Stopped",
+ "on": "Moving"
+ }
+ },
+ "status": {
+ "name": "Status",
+ "state": {
+ "off": "Offline",
+ "on": "Online"
+ }
+ }
+ },
"sensor": {
"address": {
"name": "Address"
diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py
index 5174a1a7796..69a6ec423ae 100644
--- a/homeassistant/components/unifi/__init__.py
+++ b/homeassistant/components/unifi/__init__.py
@@ -7,6 +7,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
@@ -73,6 +74,18 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return await hub.async_reset()
+async def async_remove_config_entry_device(
+ hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
+) -> bool:
+ """Remove config entry from a device."""
+ hub: UnifiHub = hass.data[UNIFI_DOMAIN][config_entry.entry_id]
+ return not any(
+ identifier
+ for _, identifier in device_entry.connections
+ if identifier in hub.api.clients or identifier in hub.api.devices
+ )
+
+
class UnifiWirelessClients:
"""Class to store clients known to be wireless.
diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py
index a41d1942536..dc48b9c31fe 100644
--- a/homeassistant/components/unifi/device_tracker.py
+++ b/homeassistant/components/unifi/device_tracker.py
@@ -240,7 +240,7 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity):
self._ignore_events = False
self._is_connected = description.is_connected_fn(self.hub, self._obj_id)
if self.is_connected:
- self.hub.async_heartbeat(
+ self.hub.update_heartbeat(
self.unique_id,
dt_util.utcnow()
+ description.heartbeat_timedelta_fn(self.hub, self._obj_id),
@@ -301,12 +301,12 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity):
# From unifi.entity.async_signal_reachable_callback
# Controller connection state has changed and entity is unavailable
# Cancel heartbeat
- self.hub.async_heartbeat(self.unique_id)
+ self.hub.remove_heartbeat(self.unique_id)
return
if is_connected := description.is_connected_fn(self.hub, self._obj_id):
self._is_connected = is_connected
- self.hub.async_heartbeat(
+ self.hub.update_heartbeat(
self.unique_id,
dt_util.utcnow()
+ description.heartbeat_timedelta_fn(self.hub, self._obj_id),
@@ -319,12 +319,12 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity):
return
if event.key in self._event_is_on:
- self.hub.async_heartbeat(self.unique_id)
+ self.hub.remove_heartbeat(self.unique_id)
self._is_connected = True
self.async_write_ha_state()
return
- self.hub.async_heartbeat(
+ self.hub.update_heartbeat(
self.unique_id,
dt_util.utcnow()
+ self.entity_description.heartbeat_timedelta_fn(self.hub, self._obj_id),
@@ -344,7 +344,7 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity):
async def async_will_remove_from_hass(self) -> None:
"""Disconnect object when removed."""
await super().async_will_remove_from_hass()
- self.hub.async_heartbeat(self.unique_id)
+ self.hub.remove_heartbeat(self.unique_id)
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
diff --git a/homeassistant/components/unifi/hub/entity_helper.py b/homeassistant/components/unifi/hub/entity_helper.py
new file mode 100644
index 00000000000..c4bcf237386
--- /dev/null
+++ b/homeassistant/components/unifi/hub/entity_helper.py
@@ -0,0 +1,156 @@
+"""UniFi Network entity helper."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+import aiounifi
+from aiounifi.models.device import DeviceSetPoePortModeRequest
+
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_call_later, async_track_time_interval
+import homeassistant.util.dt as dt_util
+
+
+class UnifiEntityHelper:
+ """UniFi Network integration handling platforms for entity registration."""
+
+ def __init__(self, hass: HomeAssistant, api: aiounifi.Controller) -> None:
+ """Initialize the UniFi entity loader."""
+ self.hass = hass
+ self.api = api
+
+ self._device_command = UnifiDeviceCommand(hass, api)
+ self._heartbeat = UnifiEntityHeartbeat(hass)
+
+ @callback
+ def reset(self) -> None:
+ """Cancel timers."""
+ self._device_command.reset()
+ self._heartbeat.reset()
+
+ @callback
+ def initialize(self) -> None:
+ """Initialize entity helper."""
+ self._heartbeat.initialize()
+
+ @property
+ def signal_heartbeat(self) -> str:
+ """Event to signal new heartbeat missed."""
+ return self._heartbeat.signal
+
+ @callback
+ def update_heartbeat(self, unique_id: str, heartbeat_expire_time: datetime) -> None:
+ """Update device time in heartbeat monitor."""
+ self._heartbeat.update(unique_id, heartbeat_expire_time)
+
+ @callback
+ def remove_heartbeat(self, unique_id: str) -> None:
+ """Update device time in heartbeat monitor."""
+ self._heartbeat.remove(unique_id)
+
+ @callback
+ def queue_poe_port_command(
+ self, device_id: str, port_idx: int, poe_mode: str
+ ) -> None:
+ """Queue commands to execute them together per device."""
+ self._device_command.queue_poe_command(device_id, port_idx, poe_mode)
+
+
+class UnifiEntityHeartbeat:
+ """UniFi entity heartbeat monitor."""
+
+ CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1)
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the heartbeat monitor."""
+ self.hass = hass
+
+ self._cancel_heartbeat_check: CALLBACK_TYPE | None = None
+ self._heartbeat_time: dict[str, datetime] = {}
+
+ @callback
+ def reset(self) -> None:
+ """Cancel timers."""
+ if self._cancel_heartbeat_check:
+ self._cancel_heartbeat_check()
+ self._cancel_heartbeat_check = None
+
+ @callback
+ def initialize(self) -> None:
+ """Initialize heartbeat monitor."""
+ self._cancel_heartbeat_check = async_track_time_interval(
+ self.hass, self._check_for_stale, self.CHECK_HEARTBEAT_INTERVAL
+ )
+
+ @property
+ def signal(self) -> str:
+ """Event to signal new heartbeat missed."""
+ return "unifi-heartbeat-missed"
+
+ @callback
+ def update(self, unique_id: str, heartbeat_expire_time: datetime) -> None:
+ """Update device time in heartbeat monitor."""
+ self._heartbeat_time[unique_id] = heartbeat_expire_time
+
+ @callback
+ def remove(self, unique_id: str) -> None:
+ """Remove device from heartbeat monitor."""
+ self._heartbeat_time.pop(unique_id, None)
+
+ @callback
+ def _check_for_stale(self, *_: datetime) -> None:
+ """Check for any devices scheduled to be marked disconnected."""
+ now = dt_util.utcnow()
+
+ unique_ids_to_remove = []
+ for unique_id, heartbeat_expire_time in self._heartbeat_time.items():
+ if now > heartbeat_expire_time:
+ async_dispatcher_send(self.hass, f"{self.signal}_{unique_id}")
+ unique_ids_to_remove.append(unique_id)
+
+ for unique_id in unique_ids_to_remove:
+ del self._heartbeat_time[unique_id]
+
+
+class UnifiDeviceCommand:
+ """UniFi Device command helper class."""
+
+ COMMAND_DELAY = 5
+
+ def __init__(self, hass: HomeAssistant, api: aiounifi.Controller) -> None:
+ """Initialize device command helper."""
+ self.hass = hass
+ self.api = api
+
+ self._command_queue: dict[str, dict[int, str]] = {}
+ self._cancel_command: CALLBACK_TYPE | None = None
+
+ @callback
+ def reset(self) -> None:
+ """Cancel timers."""
+ if self._cancel_command:
+ self._cancel_command()
+ self._cancel_command = None
+
+ @callback
+ def queue_poe_command(self, device_id: str, port_idx: int, poe_mode: str) -> None:
+ """Queue commands to execute them together per device."""
+ self.reset()
+
+ device_queue = self._command_queue.setdefault(device_id, {})
+ device_queue[port_idx] = poe_mode
+
+ async def _command(now: datetime) -> None:
+ """Execute previously queued commands."""
+ queue = self._command_queue.copy()
+ self._command_queue.clear()
+ for device_id, device_commands in queue.items():
+ device = self.api.devices[device_id]
+ commands = list(device_commands.items())
+ await self.api.request(
+ DeviceSetPoePortModeRequest.create(device, targets=commands)
+ )
+
+ self._cancel_command = async_call_later(self.hass, self.COMMAND_DELAY, _command)
diff --git a/homeassistant/components/unifi/hub/hub.py b/homeassistant/components/unifi/hub/hub.py
index df91584f267..f8c1f2517a2 100644
--- a/homeassistant/components/unifi/hub/hub.py
+++ b/homeassistant/components/unifi/hub/hub.py
@@ -2,13 +2,12 @@
from __future__ import annotations
-from datetime import datetime, timedelta
+from datetime import datetime
import aiounifi
-from aiounifi.models.device import DeviceSetPoePortModeRequest
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
+from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import (
DeviceEntry,
@@ -16,16 +15,13 @@ from homeassistant.helpers.device_registry import (
DeviceInfo,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.event import async_call_later, async_track_time_interval
-import homeassistant.util.dt as dt_util
from ..const import ATTR_MANUFACTURER, CONF_SITE_ID, DOMAIN as UNIFI_DOMAIN, PLATFORMS
from .config import UnifiConfig
+from .entity_helper import UnifiEntityHelper
from .entity_loader import UnifiEntityLoader
from .websocket import UnifiWebsocket
-CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1)
-
class UnifiHub:
"""Manages a single UniFi Network instance."""
@@ -38,17 +34,12 @@ class UnifiHub:
self.api = api
self.config = UnifiConfig.from_config_entry(config_entry)
self.entity_loader = UnifiEntityLoader(self)
+ self._entity_helper = UnifiEntityHelper(hass, api)
self.websocket = UnifiWebsocket(hass, api, self.signal_reachable)
self.site = config_entry.data[CONF_SITE_ID]
self.is_admin = False
- self._cancel_heartbeat_check: CALLBACK_TYPE | None = None
- self._heartbeat_time: dict[str, datetime] = {}
-
- self.poe_command_queue: dict[str, dict[int, str]] = {}
- self._cancel_poe_command: CALLBACK_TYPE | None = None
-
@callback
@staticmethod
def get_hub(hass: HomeAssistant, config_entry: ConfigEntry) -> UnifiHub:
@@ -61,6 +52,28 @@ class UnifiHub:
"""Websocket connection state."""
return self.websocket.available
+ @property
+ def signal_heartbeat_missed(self) -> str:
+ """Event to signal new heartbeat missed."""
+ return self._entity_helper.signal_heartbeat
+
+ @callback
+ def update_heartbeat(self, unique_id: str, heartbeat_expire_time: datetime) -> None:
+ """Update device time in heartbeat monitor."""
+ self._entity_helper.update_heartbeat(unique_id, heartbeat_expire_time)
+
+ @callback
+ def remove_heartbeat(self, unique_id: str) -> None:
+ """Update device time in heartbeat monitor."""
+ self._entity_helper.remove_heartbeat(unique_id)
+
+ @callback
+ def queue_poe_port_command(
+ self, device_id: str, port_idx: int, poe_mode: str
+ ) -> None:
+ """Queue commands to execute them together per device."""
+ self._entity_helper.queue_poe_port_command(device_id, port_idx, poe_mode)
+
@property
def signal_reachable(self) -> str:
"""Integration specific event to signal a change in connection status."""
@@ -71,77 +84,16 @@ class UnifiHub:
"""Event specific per UniFi entry to signal new options."""
return f"unifi-options-{self.config.entry.entry_id}"
- @property
- def signal_heartbeat_missed(self) -> str:
- """Event specific per UniFi device tracker to signal new heartbeat missed."""
- return "unifi-heartbeat-missed"
-
async def initialize(self) -> None:
"""Set up a UniFi Network instance."""
await self.entity_loader.initialize()
+ self._entity_helper.initialize()
assert self.config.entry.unique_id is not None
self.is_admin = self.api.sites[self.config.entry.unique_id].role == "admin"
self.config.entry.add_update_listener(self.async_config_entry_updated)
- self._cancel_heartbeat_check = async_track_time_interval(
- self.hass, self._async_check_for_stale, CHECK_HEARTBEAT_INTERVAL
- )
-
- @callback
- def async_heartbeat(
- self, unique_id: str, heartbeat_expire_time: datetime | None = None
- ) -> None:
- """Signal when a device has fresh home state."""
- if heartbeat_expire_time is not None:
- self._heartbeat_time[unique_id] = heartbeat_expire_time
- return
-
- if unique_id in self._heartbeat_time:
- del self._heartbeat_time[unique_id]
-
- @callback
- def _async_check_for_stale(self, *_: datetime) -> None:
- """Check for any devices scheduled to be marked disconnected."""
- now = dt_util.utcnow()
-
- unique_ids_to_remove = []
- for unique_id, heartbeat_expire_time in self._heartbeat_time.items():
- if now > heartbeat_expire_time:
- async_dispatcher_send(
- self.hass, f"{self.signal_heartbeat_missed}_{unique_id}"
- )
- unique_ids_to_remove.append(unique_id)
-
- for unique_id in unique_ids_to_remove:
- del self._heartbeat_time[unique_id]
-
- @callback
- def async_queue_poe_port_command(
- self, device_id: str, port_idx: int, poe_mode: str
- ) -> None:
- """Queue commands to execute them together per device."""
- if self._cancel_poe_command:
- self._cancel_poe_command()
- self._cancel_poe_command = None
-
- device_queue = self.poe_command_queue.setdefault(device_id, {})
- device_queue[port_idx] = poe_mode
-
- async def async_execute_command(now: datetime) -> None:
- """Execute previously queued commands."""
- queue = self.poe_command_queue.copy()
- self.poe_command_queue.clear()
- for device_id, device_commands in queue.items():
- device = self.api.devices[device_id]
- commands = list(device_commands.items())
- await self.api.request(
- DeviceSetPoePortModeRequest.create(device, targets=commands)
- )
-
- self._cancel_poe_command = async_call_later(self.hass, 5, async_execute_command)
-
@property
def device_info(self) -> DeviceInfo:
"""UniFi Network device info."""
@@ -205,12 +157,6 @@ class UnifiHub:
if not unload_ok:
return False
- if self._cancel_heartbeat_check:
- self._cancel_heartbeat_check()
- self._cancel_heartbeat_check = None
-
- if self._cancel_poe_command:
- self._cancel_poe_command()
- self._cancel_poe_command = None
+ self._entity_helper.reset()
return True
diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json
index 05dc2189908..982d654c8fe 100644
--- a/homeassistant/components/unifi/manifest.json
+++ b/homeassistant/components/unifi/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["aiounifi"],
"quality_scale": "platinum",
- "requirements": ["aiounifi==74"],
+ "requirements": ["aiounifi==76"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py
index 360f40384c9..17b3cae93fd 100644
--- a/homeassistant/components/unifi/sensor.py
+++ b/homeassistant/components/unifi/sensor.py
@@ -239,6 +239,42 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = (
unique_id_fn=lambda hub, obj_id: f"poe_power-{obj_id}",
value_fn=lambda _, obj: obj.poe_power if obj.poe_mode != "off" else "0",
),
+ UnifiSensorEntityDescription[Ports, Port](
+ key="Port Bandwidth sensor RX",
+ device_class=SensorDeviceClass.DATA_RATE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
+ suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
+ icon="mdi:download",
+ allowed_fn=lambda hub, _: hub.config.option_allow_bandwidth_sensors,
+ api_handler_fn=lambda api: api.ports,
+ available_fn=async_device_available_fn,
+ device_info_fn=async_device_device_info_fn,
+ name_fn=lambda port: f"{port.name} RX",
+ object_fn=lambda api, obj_id: api.ports[obj_id],
+ unique_id_fn=lambda hub, obj_id: f"port_rx-{obj_id}",
+ value_fn=lambda hub, port: port.rx_bytes_r,
+ ),
+ UnifiSensorEntityDescription[Ports, Port](
+ key="Port Bandwidth sensor TX",
+ device_class=SensorDeviceClass.DATA_RATE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
+ suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
+ icon="mdi:upload",
+ allowed_fn=lambda hub, _: hub.config.option_allow_bandwidth_sensors,
+ api_handler_fn=lambda api: api.ports,
+ available_fn=async_device_available_fn,
+ device_info_fn=async_device_device_info_fn,
+ name_fn=lambda port: f"{port.name} TX",
+ object_fn=lambda api, obj_id: api.ports[obj_id],
+ unique_id_fn=lambda hub, obj_id: f"port_tx-{obj_id}",
+ value_fn=lambda hub, port: port.tx_bytes_r,
+ ),
UnifiSensorEntityDescription[Clients, Client](
key="Client uptime",
device_class=SensorDeviceClass.TIMESTAMP,
@@ -350,19 +386,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = (
value_fn=async_device_state_value_fn,
options=list(DEVICE_STATES.values()),
),
- UnifiSensorEntityDescription[Wlans, Wlan](
- key="WLAN password",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- api_handler_fn=lambda api: api.wlans,
- available_fn=async_wlan_available_fn,
- device_info_fn=async_wlan_device_info_fn,
- name_fn=lambda wlan: "Password",
- object_fn=lambda api, obj_id: api.wlans[obj_id],
- supported_fn=lambda hub, obj_id: hub.api.wlans[obj_id].x_passphrase is not None,
- unique_id_fn=lambda hub, obj_id: f"password-{obj_id}",
- value_fn=lambda hub, obj: obj.x_passphrase,
- ),
UnifiSensorEntityDescription[Devices, Device](
key="Device CPU utilization",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -437,7 +460,7 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity):
if description.is_connected_fn is not None:
# Send heartbeat if client is connected
if description.is_connected_fn(self.hub, self._obj_id):
- self.hub.async_heartbeat(
+ self.hub.update_heartbeat(
self._attr_unique_id,
dt_util.utcnow() + self.hub.config.option_detection_time,
)
@@ -462,4 +485,4 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity):
if self.entity_description.is_connected_fn is not None:
# Remove heartbeat registration
- self.hub.async_heartbeat(self._attr_unique_id)
+ self.hub.remove_heartbeat(self._attr_unique_id)
diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py
index 6e073a655a5..45357dd67d2 100644
--- a/homeassistant/components/unifi/switch.py
+++ b/homeassistant/components/unifi/switch.py
@@ -147,7 +147,7 @@ async def async_poe_port_control_fn(hub: UnifiHub, obj_id: str, target: bool) ->
port = hub.api.ports[obj_id]
on_state = "auto" if port.raw["poe_caps"] != 8 else "passthrough"
state = on_state if target else "off"
- hub.async_queue_poe_port_command(mac, int(index), state)
+ hub.queue_poe_port_command(mac, int(index), state)
async def async_port_forward_control_fn(
diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py
index ea03c4b15f1..479b7f02024 100644
--- a/homeassistant/components/velbus/__init__.py
+++ b/homeassistant/components/velbus/__init__.py
@@ -145,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle a clear cache service call."""
# clear the cache
with suppress(FileNotFoundError):
- if CONF_ADDRESS in call.data and call.data[CONF_ADDRESS]:
+ if call.data.get(CONF_ADDRESS):
await hass.async_add_executor_job(
os.unlink,
hass.config.path(
diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py
index f37de104659..823d682d339 100644
--- a/homeassistant/components/velbus/cover.py
+++ b/homeassistant/components/velbus/cover.py
@@ -34,6 +34,7 @@ class VelbusCover(VelbusEntity, CoverEntity):
"""Representation a Velbus cover."""
_channel: VelbusBlind
+ _assumed_closed: bool
def __init__(self, channel: VelbusBlind) -> None:
"""Initialize the cover."""
@@ -51,11 +52,16 @@ class VelbusCover(VelbusEntity, CoverEntity):
| CoverEntityFeature.CLOSE
| CoverEntityFeature.STOP
)
+ self._attr_assumed_state = True
+ # guess the state to get the open/closed icons somewhat working
+ self._assumed_closed = False
@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
- return self._channel.is_closed()
+ if self._channel.support_position():
+ return self._channel.is_closed()
+ return self._assumed_closed
@property
def is_opening(self) -> bool:
@@ -83,11 +89,13 @@ class VelbusCover(VelbusEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self._channel.open()
+ self._assumed_closed = False
@api_call
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self._channel.close()
+ self._assumed_closed = True
@api_call
async def async_stop_cover(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py
index 227356a2525..da2bc2ced2b 100644
--- a/homeassistant/components/verisure/lock.py
+++ b/homeassistant/components/verisure/lock.py
@@ -112,7 +112,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt
digits = self.coordinator.entry.options.get(
CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS
)
- return "^\\d{%s}$" % digits
+ return f"^\\d{{{digits}}}$"
@property
def is_locked(self) -> bool:
diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py
index 12de3af1cb0..03caa723771 100644
--- a/homeassistant/components/vizio/const.py
+++ b/homeassistant/components/vizio/const.py
@@ -52,7 +52,9 @@ DEVICE_ID = "pyvizio"
DOMAIN = "vizio"
COMMON_SUPPORTED_COMMANDS = (
- MediaPlayerEntityFeature.SELECT_SOURCE
+ MediaPlayerEntityFeature.PAUSE
+ | MediaPlayerEntityFeature.PLAY
+ | MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_MUTE
diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py
index c19c091bb3d..18af2c0dbb2 100644
--- a/homeassistant/components/vizio/media_player.py
+++ b/homeassistant/components/vizio/media_player.py
@@ -159,6 +159,7 @@ class VizioDevice(MediaPlayerEntity):
)
self._device = device
self._max_volume = float(device.get_max_volume())
+ self._attr_assumed_state = True
# Entity class attributes that will change with each update (we only include
# the ones that are initialized differently from the defaults)
@@ -483,3 +484,11 @@ class VizioDevice(MediaPlayerEntity):
num = int(self._max_volume * (self._attr_volume_level - volume))
await self._device.vol_down(num=num, log_api_exception=False)
self._attr_volume_level = volume
+
+ async def async_media_play(self) -> None:
+ """Play whatever media is currently active."""
+ await self._device.play(log_api_exception=False)
+
+ async def async_media_pause(self) -> None:
+ """Pause whatever media is currently active."""
+ await self._device.pause(log_api_exception=False)
diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json
index ced871b7616..7e2e974e709 100644
--- a/homeassistant/components/vodafone_station/manifest.json
+++ b/homeassistant/components/vodafone_station/manifest.json
@@ -4,7 +4,9 @@
"codeowners": ["@paoloantinori", "@chemelli74"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/vodafone_station",
+ "integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiovodafone"],
+ "quality_scale": "silver",
"requirements": ["aiovodafone==0.5.4"]
}
diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py
index 937c0220cbf..2a08a9b2ebe 100644
--- a/homeassistant/components/vodafone_station/sensor.py
+++ b/homeassistant/components/vodafone_station/sensor.py
@@ -107,12 +107,12 @@ SENSOR_TYPES: Final = (
VodafoneStationEntityDescription(
key="phone_num1",
translation_key="phone_num1",
- is_suitable=lambda info: info["phone_unavailable1"] == "0",
+ is_suitable=lambda info: info["phone_num1"] != "",
),
VodafoneStationEntityDescription(
key="phone_num2",
translation_key="phone_num2",
- is_suitable=lambda info: info["phone_unavailable2"] == "0",
+ is_suitable=lambda info: info["phone_num2"] != "",
),
VodafoneStationEntityDescription(
key="sys_uptime",
diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py
index a0b54fd8db0..e5c3a055310 100644
--- a/homeassistant/components/wake_on_lan/switch.py
+++ b/homeassistant/components/wake_on_lan/switch.py
@@ -129,7 +129,7 @@ class WolSwitch(SwitchEntity):
if self._attr_assumed_state:
self._state = True
- self.async_write_ha_state()
+ self.schedule_update_ha_state()
def turn_off(self, **kwargs: Any) -> None:
"""Turn the device off if an off action is present."""
@@ -138,7 +138,7 @@ class WolSwitch(SwitchEntity):
if self._attr_assumed_state:
self._state = False
- self.async_write_ha_state()
+ self.schedule_update_ha_state()
def update(self) -> None:
"""Check if device is on and update the state. Only called if assumed state is false."""
diff --git a/homeassistant/components/weatherflow_cloud/config_flow.py b/homeassistant/components/weatherflow_cloud/config_flow.py
index 4c905a8451e..e8972c320ed 100644
--- a/homeassistant/components/weatherflow_cloud/config_flow.py
+++ b/homeassistant/components/weatherflow_cloud/config_flow.py
@@ -50,6 +50,7 @@ class WeatherFlowCloudConfigFlow(ConfigFlow, domain=DOMAIN):
existing_entry,
data={CONF_API_TOKEN: api_token},
reason="reauth_successful",
+ reload_even_if_entry_is_unchanged=False,
)
return self.async_show_form(
diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py
index 1fe85f180da..0b86a2b5201 100644
--- a/homeassistant/components/withings/__init__.py
+++ b/homeassistant/components/withings/__init__.py
@@ -12,6 +12,7 @@ from dataclasses import dataclass, field
from datetime import timedelta
from typing import TYPE_CHECKING, Any, cast
+from aiohttp import ClientError
from aiohttp.hdrs import METH_POST
from aiohttp.web import Request, Response
from aiowithings import NotificationCategory, WithingsClient
@@ -274,7 +275,11 @@ class WithingsWebhookManager:
async def async_unsubscribe_webhooks(client: WithingsClient) -> None:
"""Unsubscribe to all Withings webhooks."""
- current_webhooks = await client.list_notification_configurations()
+ try:
+ current_webhooks = await client.list_notification_configurations()
+ except ClientError:
+ LOGGER.exception("Error when unsubscribing webhooks")
+ return
for webhook_configuration in current_webhooks:
LOGGER.debug(
diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json
index 6b51c0fb2cb..88dcce39993 100644
--- a/homeassistant/components/wolflink/manifest.json
+++ b/homeassistant/components/wolflink/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "wolflink",
"name": "Wolf SmartSet Service",
- "codeowners": ["@adamkrol93"],
+ "codeowners": ["@adamkrol93", "@mtielen"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/wolflink",
"iot_class": "cloud_polling",
"loggers": ["wolf_comm"],
- "requirements": ["wolf-comm==0.0.6"]
+ "requirements": ["wolf-comm==0.0.7"]
}
diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json
index 314f4c6bcf4..e0813cd90cd 100644
--- a/homeassistant/components/workday/manifest.json
+++ b/homeassistant/components/workday/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
- "requirements": ["holidays==0.46"]
+ "requirements": ["holidays==0.47"]
}
diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py
index cd3b3192520..5baaf614b01 100644
--- a/homeassistant/components/xiaomi_miio/remote.py
+++ b/homeassistant/components/xiaomi_miio/remote.py
@@ -138,8 +138,8 @@ async def async_setup_platform(
message = await hass.async_add_executor_job(device.read, slot)
_LOGGER.debug("Message received from device: '%s'", message)
- if "code" in message and message["code"]:
- log_msg = "Received command is: {}".format(message["code"])
+ if code := message.get("code"):
+ log_msg = f"Received command is: {code}"
_LOGGER.info(log_msg)
persistent_notification.async_create(
hass, log_msg, title="Xiaomi Miio Remote"
diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json
index cd6759b5864..b7bd1d4784f 100644
--- a/homeassistant/components/yolink/manifest.json
+++ b/homeassistant/components/yolink/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["auth", "application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/yolink",
"iot_class": "cloud_push",
- "requirements": ["yolink-api==0.4.2"]
+ "requirements": ["yolink-api==0.4.3"]
}
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index 7741673557d..452f11db85b 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -21,12 +21,12 @@
"universal_silabs_flasher"
],
"requirements": [
- "bellows==0.38.1",
+ "bellows==0.38.2",
"pyserial==3.5",
"pyserial-asyncio==0.6",
- "zha-quirks==0.0.114",
+ "zha-quirks==0.0.115",
"zigpy-deconz==0.23.1",
- "zigpy==0.63.5",
+ "zigpy==0.64.0",
"zigpy-xbee==0.20.1",
"zigpy-zigate==0.12.0",
"zigpy-znp==0.12.1",
diff --git a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py
index 5b1f85e1a29..4ee10c7bb93 100644
--- a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py
+++ b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py
@@ -74,9 +74,14 @@ def _detect_radio_hardware(hass: HomeAssistant, device: str) -> HardwareType:
return HardwareType.OTHER
-async def probe_silabs_firmware_type(device: str) -> ApplicationType | None:
+async def probe_silabs_firmware_type(
+ device: str, *, probe_methods: ApplicationType | None = None
+) -> ApplicationType | None:
"""Probe the running firmware on a Silabs device."""
- flasher = Flasher(device=device)
+ flasher = Flasher(
+ device=device,
+ **({"probe_methods": probe_methods} if probe_methods else {}),
+ )
try:
await flasher.probe_app_type()
diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py
index b3f54ae9904..413186da9bf 100644
--- a/homeassistant/components/zwave_js/siren.py
+++ b/homeassistant/components/zwave_js/siren.py
@@ -63,7 +63,8 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity):
super().__init__(config_entry, driver, info)
# Entity class attributes
self._attr_available_tones = {
- int(id): val for id, val in self.info.primary_value.metadata.states.items()
+ int(state_id): val
+ for state_id, val in self.info.primary_value.metadata.states.items()
}
self._attr_supported_features = (
SirenEntityFeature.TURN_ON
diff --git a/homeassistant/config.py b/homeassistant/config.py
index 61b346944fa..abb29f6a1a1 100644
--- a/homeassistant/config.py
+++ b/homeassistant/config.py
@@ -39,6 +39,7 @@ from .const import (
CONF_CUSTOMIZE,
CONF_CUSTOMIZE_DOMAIN,
CONF_CUSTOMIZE_GLOB,
+ CONF_DEBUG,
CONF_ELEVATION,
CONF_EXTERNAL_URL,
CONF_ID,
@@ -391,6 +392,7 @@ CORE_CONFIG_SCHEMA = vol.All(
vol.Optional(CONF_CURRENCY): _validate_currency,
vol.Optional(CONF_COUNTRY): cv.country,
vol.Optional(CONF_LANGUAGE): cv.language,
+ vol.Optional(CONF_DEBUG): cv.boolean,
}
),
_filter_bad_internal_external_urls,
@@ -899,6 +901,9 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non
if key in config:
setattr(hac, attr, config[key])
+ if config.get(CONF_DEBUG):
+ hac.debug = True
+
_raise_issue_if_legacy_templates(hass, config.get(CONF_LEGACY_TEMPLATES))
_raise_issue_if_historic_currency(hass, hass.config.currency)
_raise_issue_if_no_country(hass, hass.config.country)
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index bf576b517d3..056814bbc4d 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -1405,7 +1405,9 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]):
@callback
def _async_discovery(self) -> None:
"""Handle discovery."""
- self.hass.bus.async_fire(EVENT_FLOW_DISCOVERED)
+ # async_fire_internal is used here because this is only
+ # called from the Debouncer so we know the usage is safe
+ self.hass.bus.async_fire_internal(EVENT_FLOW_DISCOVERED)
persistent_notification.async_create(
self.hass,
title="New devices discovered",
@@ -2397,6 +2399,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
data: Mapping[str, Any] | UndefinedType = UNDEFINED,
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
reason: str = "reauth_successful",
+ reload_even_if_entry_is_unchanged: bool = True,
) -> ConfigFlowResult:
"""Update config entry, reload config entry and finish config flow."""
result = self.hass.config_entries.async_update_entry(
@@ -2406,7 +2409,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
data=data,
options=options,
)
- if result:
+ if reload_even_if_entry_is_unchanged or result:
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason=reason)
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 58a1c92ea72..45ff6ecf976 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
-MINOR_VERSION: Final = 5
+MINOR_VERSION: Final = 6
PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
@@ -296,6 +296,7 @@ CONF_WHILE: Final = "while"
CONF_WHITELIST: Final = "whitelist"
CONF_ALLOWLIST_EXTERNAL_DIRS: Final = "allowlist_external_dirs"
LEGACY_CONF_WHITELIST_EXTERNAL_DIRS: Final = "whitelist_external_dirs"
+CONF_DEBUG: Final = "debug"
CONF_XY: Final = "xy"
CONF_ZONE: Final = "zone"
diff --git a/homeassistant/core.py b/homeassistant/core.py
index 01536f8ffdb..a3150adc221 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -36,7 +36,6 @@ from typing import (
TYPE_CHECKING,
Any,
Generic,
- Literal,
NotRequired,
ParamSpec,
Self,
@@ -279,17 +278,24 @@ def async_get_hass() -> HomeAssistant:
return _hass.hass
+class ReleaseChannel(enum.StrEnum):
+ BETA = "beta"
+ DEV = "dev"
+ NIGHTLY = "nightly"
+ STABLE = "stable"
+
+
@callback
-def get_release_channel() -> Literal["beta", "dev", "nightly", "stable"]:
+def get_release_channel() -> ReleaseChannel:
"""Find release channel based on version number."""
version = __version__
if "dev0" in version:
- return "dev"
+ return ReleaseChannel.DEV
if "dev" in version:
- return "nightly"
+ return ReleaseChannel.NIGHTLY
if "b" in version:
- return "beta"
- return "stable"
+ return ReleaseChannel.BETA
+ return ReleaseChannel.STABLE
@enum.unique
@@ -423,6 +429,20 @@ class HomeAssistant:
max_workers=1, thread_name_prefix="ImportExecutor"
)
+ def verify_event_loop_thread(self, what: str) -> None:
+ """Report and raise if we are not running in the event loop thread."""
+ if (
+ loop_thread_ident := self.loop.__dict__.get("_thread_ident")
+ ) and loop_thread_ident != threading.get_ident():
+ from .helpers import frame # pylint: disable=import-outside-toplevel
+
+ # frame is a circular import, so we import it here
+ frame.report(
+ f"calls {what} from a thread",
+ error_if_core=True,
+ error_if_integration=True,
+ )
+
@property
def _active_tasks(self) -> set[asyncio.Future[Any]]:
"""Return all active tasks.
@@ -497,11 +517,10 @@ class HomeAssistant:
This method is a coroutine.
"""
_LOGGER.info("Starting Home Assistant")
- setattr(self.loop, "_thread_ident", threading.get_ident())
self.set_state(CoreState.starting)
- self.bus.async_fire(EVENT_CORE_CONFIG_UPDATE)
- self.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ self.bus.async_fire_internal(EVENT_CORE_CONFIG_UPDATE)
+ self.bus.async_fire_internal(EVENT_HOMEASSISTANT_START)
if not self._tasks:
pending: set[asyncio.Future[Any]] | None = None
@@ -534,8 +553,8 @@ class HomeAssistant:
return
self.set_state(CoreState.running)
- self.bus.async_fire(EVENT_CORE_CONFIG_UPDATE)
- self.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
+ self.bus.async_fire_internal(EVENT_CORE_CONFIG_UPDATE)
+ self.bus.async_fire_internal(EVENT_HOMEASSISTANT_STARTED)
def add_job(
self, target: Callable[[*_Ts], Any] | Coroutine[Any, Any, Any], *args: *_Ts
@@ -1109,7 +1128,7 @@ class HomeAssistant:
self.exit_code = exit_code
self.set_state(CoreState.stopping)
- self.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ self.bus.async_fire_internal(EVENT_HOMEASSISTANT_STOP)
try:
async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT):
await self.async_block_till_done()
@@ -1122,7 +1141,7 @@ class HomeAssistant:
# Stage 3 - Final write
self.set_state(CoreState.final_write)
- self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE)
+ self.bus.async_fire_internal(EVENT_HOMEASSISTANT_FINAL_WRITE)
try:
async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT):
await self.async_block_till_done()
@@ -1135,7 +1154,7 @@ class HomeAssistant:
# Stage 4 - Close
self.set_state(CoreState.not_running)
- self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE)
+ self.bus.async_fire_internal(EVENT_HOMEASSISTANT_CLOSE)
# Make a copy of running_tasks since a task can finish
# while we are awaiting canceled tasks to get their result
@@ -1384,10 +1403,16 @@ class _OneTimeListener(Generic[_DataT]):
return f"<_OneTimeListener {self.listener_job.target}>"
-# Empty list, used by EventBus._async_fire
+# Empty list, used by EventBus.async_fire_internal
EMPTY_LIST: list[Any] = []
+def _verify_event_type_length_or_raise(event_type: EventType[_DataT] | str) -> None:
+ """Verify the length of the event type and raise if too long."""
+ if len(event_type) > MAX_LENGTH_EVENT_EVENT_TYPE:
+ raise MaxLengthExceeded(event_type, "event_type", MAX_LENGTH_EVENT_EVENT_TYPE)
+
+
class EventBus:
"""Allow the firing of and listening for events."""
@@ -1428,8 +1453,9 @@ class EventBus:
context: Context | None = None,
) -> None:
"""Fire an event."""
+ _verify_event_type_length_or_raise(event_type)
self._hass.loop.call_soon_threadsafe(
- self.async_fire, event_type, event_data, origin, context
+ self.async_fire_internal, event_type, event_data, origin, context
)
@callback
@@ -1445,14 +1471,14 @@ class EventBus:
This method must be run in the event loop.
"""
- if len(event_type) > MAX_LENGTH_EVENT_EVENT_TYPE:
- raise MaxLengthExceeded(
- event_type, "event_type", MAX_LENGTH_EVENT_EVENT_TYPE
- )
- return self._async_fire(event_type, event_data, origin, context, time_fired)
+ _verify_event_type_length_or_raise(event_type)
+ self._hass.verify_event_loop_thread("async_fire")
+ return self.async_fire_internal(
+ event_type, event_data, origin, context, time_fired
+ )
@callback
- def _async_fire(
+ def async_fire_internal(
self,
event_type: EventType[_DataT] | str,
event_data: _DataT | None = None,
@@ -1460,7 +1486,12 @@ class EventBus:
context: Context | None = None,
time_fired: float | None = None,
) -> None:
- """Fire an event.
+ """Fire an event, for internal use only.
+
+ This method is intended to only be used by core internally
+ and should not be considered a stable API. We will make
+ breaking change to this function in the future and it
+ should not be used in integrations.
This method must be run in the event loop.
"""
@@ -2106,7 +2137,7 @@ class StateMachine:
"old_state": old_state,
"new_state": None,
}
- self._bus._async_fire( # pylint: disable=protected-access
+ self._bus.async_fire_internal(
EVENT_STATE_CHANGED,
state_changed_data,
context=context,
@@ -2219,7 +2250,7 @@ class StateMachine:
# mypy does not understand this is only possible if old_state is not None
old_last_reported = old_state.last_reported # type: ignore[union-attr]
old_state.last_reported = now # type: ignore[union-attr]
- self._bus._async_fire( # pylint: disable=protected-access
+ self._bus.async_fire_internal(
EVENT_STATE_REPORTED,
{
"entity_id": entity_id,
@@ -2262,7 +2293,7 @@ class StateMachine:
"old_state": old_state,
"new_state": state,
}
- self._bus._async_fire( # pylint: disable=protected-access
+ self._bus.async_fire_internal(
EVENT_STATE_CHANGED,
state_changed_data,
context=context,
@@ -2425,7 +2456,7 @@ class ServiceRegistry:
"""
run_callback_threadsafe(
self._hass.loop,
- self.async_register,
+ self._async_register,
domain,
service,
service_func,
@@ -2453,6 +2484,33 @@ class ServiceRegistry:
Schema is called to coerce and validate the service data.
+ This method must be run in the event loop.
+ """
+ self._hass.verify_event_loop_thread("async_register")
+ self._async_register(
+ domain, service, service_func, schema, supports_response, job_type
+ )
+
+ @callback
+ def _async_register(
+ self,
+ domain: str,
+ service: str,
+ service_func: Callable[
+ [ServiceCall],
+ Coroutine[Any, Any, ServiceResponse | EntityServiceResponse]
+ | ServiceResponse
+ | EntityServiceResponse
+ | None,
+ ],
+ schema: vol.Schema | None = None,
+ supports_response: SupportsResponse = SupportsResponse.NONE,
+ job_type: HassJobType | None = None,
+ ) -> None:
+ """Register a service.
+
+ Schema is called to coerce and validate the service data.
+
This method must be run in the event loop.
"""
domain = domain.lower()
@@ -2471,20 +2529,29 @@ class ServiceRegistry:
else:
self._services[domain] = {service: service_obj}
- self._hass.bus.async_fire(
+ self._hass.bus.async_fire_internal(
EVENT_SERVICE_REGISTERED, {ATTR_DOMAIN: domain, ATTR_SERVICE: service}
)
def remove(self, domain: str, service: str) -> None:
"""Remove a registered service from service handler."""
run_callback_threadsafe(
- self._hass.loop, self.async_remove, domain, service
+ self._hass.loop, self._async_remove, domain, service
).result()
@callback
def async_remove(self, domain: str, service: str) -> None:
"""Remove a registered service from service handler.
+ This method must be run in the event loop.
+ """
+ self._hass.verify_event_loop_thread("async_remove")
+ self._async_remove(domain, service)
+
+ @callback
+ def _async_remove(self, domain: str, service: str) -> None:
+ """Remove a registered service from service handler.
+
This method must be run in the event loop.
"""
domain = domain.lower()
@@ -2499,7 +2566,7 @@ class ServiceRegistry:
if not self._services[domain]:
self._services.pop(domain)
- self._hass.bus.async_fire(
+ self._hass.bus.async_fire_internal(
EVENT_SERVICE_REMOVED, {ATTR_DOMAIN: domain, ATTR_SERVICE: service}
)
@@ -2583,7 +2650,7 @@ class ServiceRegistry:
if handler.supports_response is SupportsResponse.NONE:
raise ServiceValidationError(
translation_domain=DOMAIN,
- translation_key="service_does_not_supports_reponse",
+ translation_key="service_does_not_support_response",
translation_placeholders={
"return_response": "return_response=True"
},
@@ -2616,7 +2683,7 @@ class ServiceRegistry:
domain, service, processed_data, context, return_response
)
- self._hass.bus._async_fire( # pylint: disable=protected-access
+ self._hass.bus.async_fire_internal(
EVENT_CALL_SERVICE,
{
ATTR_DOMAIN: domain,
@@ -2736,6 +2803,7 @@ class Config:
self.elevation: int = 0
"""Elevation (always in meters regardless of the unit system)."""
+ self.debug: bool = False
self.location_name: str = "Home"
self.time_zone: str = "UTC"
self.units: UnitSystem = METRIC_SYSTEM
@@ -2876,6 +2944,7 @@ class Config:
"country": self.country,
"language": self.language,
"safe_mode": self.safe_mode,
+ "debug": self.debug,
}
def set_time_zone(self, time_zone_str: str) -> None:
@@ -2942,7 +3011,7 @@ class Config:
self._update(source=ConfigSource.STORAGE, **kwargs)
await self._async_store()
- self.hass.bus.async_fire(EVENT_CORE_CONFIG_UPDATE, kwargs)
+ self.hass.bus.async_fire_internal(EVENT_CORE_CONFIG_UPDATE, kwargs)
_raise_issue_if_historic_currency(self.hass, self.currency)
_raise_issue_if_no_country(self.hass, self.country)
diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py
index 7e7019681af..f628879a7fd 100644
--- a/homeassistant/data_entry_flow.py
+++ b/homeassistant/data_entry_flow.py
@@ -442,7 +442,7 @@ class FlowManager(abc.ABC, Generic[_FlowResultT, _HandlerT]):
)
):
# Tell frontend to reload the flow state.
- self.hass.bus.async_fire(
+ self.hass.bus.async_fire_internal(
EVENT_DATA_ENTRY_FLOW_PROGRESSED,
{"handler": flow.handler, "flow_id": flow_id, "refresh": True},
)
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 6740c39b016..6d59a731879 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -152,6 +152,7 @@ FLOWS = {
"enocean",
"enphase_envoy",
"environment_canada",
+ "epic_games_store",
"epion",
"epson",
"eq3btsmart",
@@ -174,6 +175,7 @@ FLOWS = {
"flo",
"flume",
"flux_led",
+ "folder_watcher",
"forecast_solar",
"forked_daapd",
"foscam",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 77246604df9..cd0e449bd09 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -1649,6 +1649,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "epic_games_store": {
+ "name": "Epic Games Store",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"epion": {
"name": "Epion",
"integration_type": "hub",
@@ -1950,7 +1956,7 @@
"folder_watcher": {
"name": "Folder Watcher",
"integration_type": "hub",
- "config_flow": false,
+ "config_flow": true,
"iot_class": "local_polling"
},
"foobot": {
@@ -2559,6 +2565,11 @@
"integration_type": "virtual",
"supported_by": "netatmo"
},
+ "homeassistant_sky_connect": {
+ "name": "Home Assistant SkyConnect",
+ "integration_type": "device",
+ "config_flow": true
+ },
"homematic": {
"name": "Homematic",
"integrations": {
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index 38287eb6722..bf20a2d7f5f 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -1106,7 +1106,7 @@ def empty_config_schema(domain: str) -> Callable[[dict], dict]:
"""Return a config schema which logs if there are configuration parameters."""
def validator(config: dict) -> dict:
- if domain in config and config[domain]:
+ if config_domain := config.get(domain):
get_integration_logger(__name__).error(
(
"The %s integration does not support any configuration parameters, "
@@ -1114,7 +1114,7 @@ def empty_config_schema(domain: str) -> Callable[[dict], dict]:
"configuration."
),
domain,
- config[domain],
+ config_domain,
)
return config
diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py
index 3a9d047810b..aec5dbc6c4a 100644
--- a/homeassistant/helpers/device_registry.py
+++ b/homeassistant/helpers/device_registry.py
@@ -13,7 +13,13 @@ import attr
from yarl import URL
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
-from homeassistant.core import Event, HomeAssistant, callback, get_release_channel
+from homeassistant.core import (
+ Event,
+ HomeAssistant,
+ ReleaseChannel,
+ callback,
+ get_release_channel,
+)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.loader import async_suggest_report_issue
from homeassistant.util.event_type import EventType
@@ -608,8 +614,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
try:
return name.format(**translation_placeholders)
except KeyError as err:
- if get_release_channel() != "stable":
- raise HomeAssistantError("Missing placeholder %s" % err) from err
+ if get_release_channel() is not ReleaseChannel.STABLE:
+ raise HomeAssistantError(f"Missing placeholder {err}") from err
report_issue = async_suggest_report_issue(
self.hass, integration_domain=domain
)
@@ -963,12 +969,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
tuple(conn) # type: ignore[misc]
for conn in device["connections"]
},
- disabled_by=DeviceEntryDisabler(device["disabled_by"])
- if device["disabled_by"]
- else None,
- entry_type=DeviceEntryType(device["entry_type"])
- if device["entry_type"]
- else None,
+ disabled_by=(
+ DeviceEntryDisabler(device["disabled_by"])
+ if device["disabled_by"]
+ else None
+ ),
+ entry_type=(
+ DeviceEntryType(device["entry_type"])
+ if device["entry_type"]
+ else None
+ ),
hw_version=device["hw_version"],
id=device["id"],
identifiers={
diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py
index c1194c7da01..aa8176a1b83 100644
--- a/homeassistant/helpers/dispatcher.py
+++ b/homeassistant/helpers/dispatcher.py
@@ -7,7 +7,12 @@ from functools import partial
import logging
from typing import Any, TypeVarTuple, overload
-from homeassistant.core import HassJob, HomeAssistant, callback
+from homeassistant.core import (
+ HassJob,
+ HomeAssistant,
+ callback,
+ get_hassjob_callable_job_type,
+)
from homeassistant.loader import bind_hass
from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.logging import catch_log_exception
@@ -161,9 +166,13 @@ def _generate_job(
signal: SignalType[*_Ts] | str, target: Callable[[*_Ts], Any] | Callable[..., Any]
) -> HassJob[..., None | Coroutine[Any, Any, None]]:
"""Generate a HassJob for a signal and target."""
+ job_type = get_hassjob_callable_job_type(target)
return HassJob(
- catch_log_exception(target, partial(_format_err, signal, target)),
+ catch_log_exception(
+ target, partial(_format_err, signal, target), job_type=job_type
+ ),
f"dispatcher {signal}",
+ job_type=job_type,
)
@@ -190,6 +199,9 @@ def async_dispatcher_send(
This method must be run in the event loop.
"""
+ if hass.config.debug:
+ hass.verify_event_loop_thread("async_dispatcher_send")
+
if (maybe_dispatchers := hass.data.get(DATA_DISPATCHER)) is None:
return
dispatchers: _DispatcherDataType[*_Ts] = maybe_dispatchers
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index 20948a7130a..a2fc16f8a82 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -52,6 +52,7 @@ from homeassistant.core import (
Event,
HassJobType,
HomeAssistant,
+ ReleaseChannel,
callback,
get_hassjob_callable_job_type,
get_release_channel,
@@ -520,6 +521,7 @@ class Entity(
# While not purely typed, it makes typehinting more useful for us
# and removes the need for constant None checks or asserts.
_state_info: StateInfo = None # type: ignore[assignment]
+ _is_custom_component: bool = False
__capabilities_updated_at: deque[float]
__capabilities_updated_at_reported: bool = False
@@ -657,8 +659,8 @@ class Entity(
return name.format(**self.translation_placeholders)
except KeyError as err:
if not self._name_translation_placeholders_reported:
- if get_release_channel() != "stable":
- raise HomeAssistantError("Missing placeholder %s" % err) from err
+ if get_release_channel() is not ReleaseChannel.STABLE:
+ raise HomeAssistantError(f"Missing placeholder {err}") from err
report_issue = self._suggest_report_issue()
_LOGGER.warning(
(
@@ -966,8 +968,8 @@ class Entity(
self._async_write_ha_state()
@callback
- def async_write_ha_state(self) -> None:
- """Write the state to the state machine."""
+ def _async_verify_state_writable(self) -> None:
+ """Verify the entity is in a writable state."""
if self.hass is None:
raise RuntimeError(f"Attribute hass is None for {self}")
@@ -992,6 +994,18 @@ class Entity(
f"No entity id specified for entity {self.name}"
)
+ @callback
+ def _async_write_ha_state_from_call_soon_threadsafe(self) -> None:
+ """Write the state to the state machine from the event loop thread."""
+ self._async_verify_state_writable()
+ self._async_write_ha_state()
+
+ @callback
+ def async_write_ha_state(self) -> None:
+ """Write the state to the state machine."""
+ self._async_verify_state_writable()
+ if self._is_custom_component or self.hass.config.debug:
+ self.hass.verify_event_loop_thread("async_write_ha_state")
self._async_write_ha_state()
def _stringify_state(self, available: bool) -> str:
@@ -1218,7 +1232,9 @@ class Entity(
f"Entity {self.entity_id} schedule update ha state",
)
else:
- self.hass.loop.call_soon_threadsafe(self.async_write_ha_state)
+ self.hass.loop.call_soon_threadsafe(
+ self._async_write_ha_state_from_call_soon_threadsafe
+ )
@callback
def async_schedule_update_ha_state(self, force_refresh: bool = False) -> None:
@@ -1423,10 +1439,12 @@ class Entity(
Not to be extended by integrations.
"""
+ is_custom_component = "custom_components" in type(self).__module__
entity_info: EntityInfo = {
"domain": self.platform.platform_name,
- "custom_component": "custom_components" in type(self).__module__,
+ "custom_component": is_custom_component,
}
+ self._is_custom_component = is_custom_component
if self.platform.config_entry:
entity_info["config_entry"] = self.platform.config_entry.entry_id
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
index 4e77df49ea6..436fc5a18de 100644
--- a/homeassistant/helpers/entity_registry.py
+++ b/homeassistant/helpers/entity_registry.py
@@ -636,7 +636,6 @@ def _validate_item(
unique_id,
report_issue,
)
- return
if (
disabled_by
and disabled_by is not UNDEFINED
diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py
index 7fae0976686..5cffe992c0d 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -3,11 +3,12 @@
from __future__ import annotations
import asyncio
+from collections import defaultdict
from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence
import copy
from dataclasses import dataclass
from datetime import datetime, timedelta
-import functools as ft
+from functools import partial, wraps
import logging
from random import randint
import time
@@ -161,7 +162,7 @@ def threaded_listener_factory(
) -> Callable[Concatenate[HomeAssistant, _P], CALLBACK_TYPE]:
"""Convert an async event helper to a threaded one."""
- @ft.wraps(async_factory)
+ @wraps(async_factory)
def factory(
hass: HomeAssistant, *args: _P.args, **kwargs: _P.kwargs
) -> CALLBACK_TYPE:
@@ -170,7 +171,7 @@ def threaded_listener_factory(
raise TypeError("First parameter needs to be a hass instance")
async_remove = run_callback_threadsafe(
- hass.loop, ft.partial(async_factory, hass, *args, **kwargs)
+ hass.loop, partial(async_factory, hass, *args, **kwargs)
).result()
def remove() -> None:
@@ -409,19 +410,16 @@ def _async_track_event(
return _remove_empty_listener
hass_data = hass.data
- callbacks_key = tracker.callbacks_key
-
- callbacks: dict[str, list[HassJob[[Event[_TypedDictT]], Any]]] | None
- if not (callbacks := hass_data.get(callbacks_key)):
- callbacks = hass_data[callbacks_key] = {}
+ callbacks: defaultdict[str, list[HassJob[[Event[_TypedDictT]], Any]]] | None
+ if not (callbacks := hass_data.get(tracker.callbacks_key)):
+ callbacks = hass_data[tracker.callbacks_key] = defaultdict(list)
listeners_key = tracker.listeners_key
-
- if listeners_key not in hass_data:
- hass_data[listeners_key] = hass.bus.async_listen(
+ if tracker.listeners_key not in hass_data:
+ hass_data[tracker.listeners_key] = hass.bus.async_listen(
tracker.event_type,
- ft.partial(tracker.dispatcher_callable, hass, callbacks),
- event_filter=ft.partial(tracker.filter_callable, hass, callbacks),
+ partial(tracker.dispatcher_callable, hass, callbacks),
+ event_filter=partial(tracker.filter_callable, hass, callbacks),
)
job = HassJob(action, f"track {tracker.event_type} event {keys}", job_type=job_type)
@@ -432,19 +430,13 @@ def _async_track_event(
# here because this function gets called ~20000 times
# during startup, and we want to avoid the overhead of
# creating empty lists and throwing them away.
- if callback_list := callbacks.get(keys):
- callback_list.append(job)
- else:
- callbacks[keys] = [job]
+ callbacks[keys].append(job)
keys = [keys]
else:
for key in keys:
- if callback_list := callbacks.get(key):
- callback_list.append(job)
- else:
- callbacks[key] = [job]
+ callbacks[key].append(job)
- return ft.partial(_remove_listener, hass, listeners_key, keys, job, callbacks)
+ return partial(_remove_listener, hass, listeners_key, keys, job, callbacks)
@callback
diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py
index d86fec3de43..068a12c0598 100644
--- a/homeassistant/helpers/frame.py
+++ b/homeassistant/helpers/frame.py
@@ -136,6 +136,7 @@ def report(
error_if_core: bool = True,
level: int = logging.WARNING,
log_custom_component_only: bool = False,
+ error_if_integration: bool = False,
) -> None:
"""Report incorrect usage.
@@ -153,14 +154,19 @@ def report(
_LOGGER.warning(msg, stack_info=True)
return
- if not log_custom_component_only or integration_frame.custom_integration:
- _report_integration(what, integration_frame, level)
+ if (
+ error_if_integration
+ or not log_custom_component_only
+ or integration_frame.custom_integration
+ ):
+ _report_integration(what, integration_frame, level, error_if_integration)
def _report_integration(
what: str,
integration_frame: IntegrationFrame,
level: int = logging.WARNING,
+ error: bool = False,
) -> None:
"""Report incorrect usage in an integration.
@@ -168,7 +174,7 @@ def _report_integration(
"""
# Keep track of integrations already reported to prevent flooding
key = f"{integration_frame.filename}:{integration_frame.line_number}"
- if key in _REPORTED_INTEGRATIONS:
+ if not error and key in _REPORTED_INTEGRATIONS:
return
_REPORTED_INTEGRATIONS.add(key)
@@ -180,11 +186,11 @@ def _report_integration(
integration_domain=integration_frame.integration,
module=integration_frame.module,
)
-
+ integration_type = "custom " if integration_frame.custom_integration else ""
_LOGGER.log(
level,
"Detected that %sintegration '%s' %s at %s, line %s: %s, please %s",
- "custom " if integration_frame.custom_integration else "",
+ integration_type,
integration_frame.integration,
what,
integration_frame.relative_filename,
@@ -192,6 +198,15 @@ def _report_integration(
integration_frame.line,
report_issue,
)
+ if not error:
+ return
+ raise RuntimeError(
+ f"Detected that {integration_type}integration "
+ f"'{integration_frame.integration}' {what} at "
+ f"{integration_frame.relative_filename}, line "
+ f"{integration_frame.line_number}: {integration_frame.line}. "
+ f"Please {report_issue}."
+ )
def warn_use(func: _CallableT, what: str) -> _CallableT:
diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py
index 6e8fa8dc3a3..d5891973e40 100644
--- a/homeassistant/helpers/network.py
+++ b/homeassistant/helpers/network.py
@@ -122,6 +122,7 @@ def get_url(
require_current_request: bool = False,
require_ssl: bool = False,
require_standard_port: bool = False,
+ require_cloud: bool = False,
allow_internal: bool = True,
allow_external: bool = True,
allow_cloud: bool = True,
@@ -145,7 +146,7 @@ def get_url(
# Try finding an URL in the order specified
for url_type in order:
- if allow_internal and url_type == TYPE_URL_INTERNAL:
+ if allow_internal and url_type == TYPE_URL_INTERNAL and not require_cloud:
with suppress(NoURLAvailableError):
return _get_internal_url(
hass,
@@ -155,7 +156,7 @@ def get_url(
require_standard_port=require_standard_port,
)
- if allow_external and url_type == TYPE_URL_EXTERNAL:
+ if require_cloud or (allow_external and url_type == TYPE_URL_EXTERNAL):
with suppress(NoURLAvailableError):
return _get_external_url(
hass,
@@ -165,7 +166,10 @@ def get_url(
require_current_request=require_current_request,
require_ssl=require_ssl,
require_standard_port=require_standard_port,
+ require_cloud=require_cloud,
)
+ if require_cloud:
+ raise NoURLAvailableError
# For current request, we accept loopback interfaces (e.g., 127.0.0.1),
# the Supervisor hostname and localhost transparently
@@ -263,8 +267,12 @@ def _get_external_url(
require_current_request: bool = False,
require_ssl: bool = False,
require_standard_port: bool = False,
+ require_cloud: bool = False,
) -> str:
"""Get external URL of this instance."""
+ if require_cloud:
+ return _get_cloud_url(hass, require_current_request=require_current_request)
+
if prefer_cloud and allow_cloud:
with suppress(NoURLAvailableError):
return _get_cloud_url(hass)
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index ea5cc3e571a..d925bf215ab 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -650,6 +650,12 @@ class _ScriptRun:
# check if condition already okay
if condition.async_template(self._hass, wait_template, self._variables, False):
self._variables["wait"]["completed"] = True
+ self._changed()
+ return
+
+ if timeout == 0:
+ self._changed()
+ self._async_handle_timeout()
return
futures, timeout_handle, timeout_future = self._async_futures_with_timeout(
@@ -778,7 +784,7 @@ class _ScriptRun:
)
trace_set_result(event=self._action[CONF_EVENT], event_data=event_data)
- self._hass.bus.async_fire(
+ self._hass.bus.async_fire_internal(
self._action[CONF_EVENT], event_data, context=self._context
)
@@ -1078,6 +1084,11 @@ class _ScriptRun:
self._variables["wait"] = {"remaining": timeout, "trigger": None}
trace_set_result(wait=self._variables["wait"])
+ if timeout == 0:
+ self._changed()
+ self._async_handle_timeout()
+ return
+
futures, timeout_handle, timeout_future = self._async_futures_with_timeout(
timeout
)
@@ -1108,6 +1119,14 @@ class _ScriptRun:
futures, timeout_handle, timeout_future, remove_triggers
)
+ def _async_handle_timeout(self) -> None:
+ """Handle timeout."""
+ self._variables["wait"]["remaining"] = 0.0
+ if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True):
+ self._log(_TIMEOUT_MSG)
+ trace_set_result(wait=self._variables["wait"], timeout=True)
+ raise _AbortScript from TimeoutError()
+
async def _async_wait_with_optional_timeout(
self,
futures: list[asyncio.Future[None]],
@@ -1118,11 +1137,7 @@ class _ScriptRun:
try:
await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED)
if timeout_future and timeout_future.done():
- self._variables["wait"]["remaining"] = 0.0
- if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True):
- self._log(_TIMEOUT_MSG)
- trace_set_result(wait=self._variables["wait"], timeout=True)
- raise _AbortScript from TimeoutError()
+ self._async_handle_timeout()
finally:
if timeout_future and not timeout_future.done() and timeout_handle:
timeout_handle.cancel()
diff --git a/homeassistant/helpers/service_info/mqtt.py b/homeassistant/helpers/service_info/mqtt.py
index 172a5eeff33..b683745e1c0 100644
--- a/homeassistant/helpers/service_info/mqtt.py
+++ b/homeassistant/helpers/service_info/mqtt.py
@@ -1,7 +1,6 @@
"""MQTT Discovery data."""
from dataclasses import dataclass
-import datetime as dt
from homeassistant.data_entry_flow import BaseServiceInfo
@@ -17,4 +16,4 @@ class MqttServiceInfo(BaseServiceInfo):
qos: int
retain: bool
subscribed_topic: str
- timestamp: dt.datetime
+ timestamp: float
diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py
index 1f0742e896d..c12494ba71b 100644
--- a/homeassistant/helpers/template.py
+++ b/homeassistant/helpers/template.py
@@ -9,7 +9,7 @@ import collections.abc
from collections.abc import Callable, Generator, Iterable
from contextlib import AbstractContextManager, suppress
from contextvars import ContextVar
-from datetime import datetime, timedelta
+from datetime import date, datetime, time, timedelta
from functools import cache, lru_cache, partial, wraps
import json
import logging
@@ -695,6 +695,8 @@ class Template:
**kwargs: Any,
) -> RenderInfo:
"""Render the template and collect an entity filter."""
+ if self.hass and self.hass.config.debug:
+ self.hass.verify_event_loop_thread("async_render_to_info")
self._renders += 1
assert self.hass and _render_info.get() is None
@@ -1347,8 +1349,8 @@ def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None:
dev_reg = device_registry.async_get(hass)
return next(
(
- id
- for id, device in dev_reg.devices.items()
+ device_id
+ for device_id, device in dev_reg.devices.items()
if (name := device.name_by_user or device.name)
and (str(entity_id_or_device_name) == name)
),
@@ -2001,12 +2003,12 @@ def square_root(value, default=_SENTINEL):
def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True, default=_SENTINEL):
"""Filter to convert given timestamp to format."""
try:
- date = dt_util.utc_from_timestamp(value)
+ result = dt_util.utc_from_timestamp(value)
if local:
- date = dt_util.as_local(date)
+ result = dt_util.as_local(result)
- return date.strftime(date_format)
+ return result.strftime(date_format)
except (ValueError, TypeError):
# If timestamp can't be converted
if default is _SENTINEL:
@@ -2048,6 +2050,12 @@ def forgiving_as_timestamp(value, default=_SENTINEL):
def as_datetime(value: Any, default: Any = _SENTINEL) -> Any:
"""Filter and to convert a time string or UNIX timestamp to datetime object."""
+ # Return datetime.datetime object without changes
+ if type(value) is datetime:
+ return value
+ # Add midnight to datetime.date object
+ if type(value) is date:
+ return datetime.combine(value, time(0, 0, 0))
try:
# Check for a valid UNIX timestamp string, int or float
timestamp = float(value)
@@ -2468,10 +2476,15 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any:
The age can be in second, minute, hour, day, month or year. Only the
biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will
be returned.
- Make sure date is not in the future, or else it will return None.
+ If the input datetime is in the future,
+ the input datetime will be returned.
If the input are not a datetime object the input will be returned unmodified.
+
+ Note: This template function is deprecated in favor of `time_until`, but is still
+ supported so as not to break old templates.
"""
+
if (render_info := _render_info.get()) is not None:
render_info.has_time = True
@@ -2484,6 +2497,50 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any:
return dt_util.get_age(value)
+def time_since(hass: HomeAssistant, value: Any | datetime, precision: int = 1) -> Any:
+ """Take a datetime and return its "age" as a string.
+
+ The age can be in seconds, minutes, hours, days, months and year.
+
+ precision is the number of units to return, with the last unit rounded.
+
+ If the value not a datetime object the input will be returned unmodified.
+ """
+ if (render_info := _render_info.get()) is not None:
+ render_info.has_time = True
+
+ if not isinstance(value, datetime):
+ return value
+ if not value.tzinfo:
+ value = dt_util.as_local(value)
+ if dt_util.now() < value:
+ return value
+
+ return dt_util.get_age(value, precision)
+
+
+def time_until(hass: HomeAssistant, value: Any | datetime, precision: int = 1) -> Any:
+ """Take a datetime and return the amount of time until that time as a string.
+
+ The time until can be in seconds, minutes, hours, days, months and years.
+
+ precision is the number of units to return, with the last unit rounded.
+
+ If the value not a datetime object the input will be returned unmodified.
+ """
+ if (render_info := _render_info.get()) is not None:
+ render_info.has_time = True
+
+ if not isinstance(value, datetime):
+ return value
+ if not value.tzinfo:
+ value = dt_util.as_local(value)
+ if dt_util.now() > value:
+ return value
+
+ return dt_util.get_time_remaining(value, precision)
+
+
def urlencode(value):
"""Urlencode dictionary and return as UTF-8 string."""
return urllib_urlencode(value).encode("utf-8")
@@ -2882,6 +2939,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
"floor_id",
"floor_name",
"relative_time",
+ "time_since",
+ "time_until",
"today_at",
"label_id",
"label_name",
@@ -2938,6 +2997,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["now"] = hassfunction(now)
self.globals["relative_time"] = hassfunction(relative_time)
self.filters["relative_time"] = self.globals["relative_time"]
+ self.globals["time_since"] = hassfunction(time_since)
+ self.filters["time_since"] = self.globals["time_since"]
+ self.globals["time_until"] = hassfunction(time_until)
+ self.filters["time_until"] = self.globals["time_until"]
self.globals["today_at"] = hassfunction(today_at)
self.filters["today_at"] = self.globals["today_at"]
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 7f134b1a93d..442db45e714 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -1,7 +1,7 @@
# Automatically generated by gen_requirements_all.py, do not edit
aiodhcpwatcher==1.0.0
-aiodiscover==2.0.0
+aiodiscover==2.1.0
aiodns==3.2.0
aiohttp-fast-url-dispatcher==0.3.0
aiohttp-isal==0.2.0
@@ -17,8 +17,8 @@ awesomeversion==24.2.0
bcrypt==4.1.2
bleak-retry-connector==3.5.0
bleak==0.21.1
-bluetooth-adapters==0.18.0
-bluetooth-auto-recovery==1.4.1
+bluetooth-adapters==0.19.0
+bluetooth-auto-recovery==1.4.2
bluetooth-data-tools==1.19.0
cached_ipaddress==0.3.0
certifi>=2021.5.30
@@ -32,14 +32,14 @@ habluetooth==2.8.0
hass-nabucasa==0.78.0
hassil==1.6.1
home-assistant-bluetooth==1.12.0
-home-assistant-frontend==20240404.2
-home-assistant-intents==2024.4.3
+home-assistant-frontend==20240424.1
+home-assistant-intents==2024.4.24
httpx==0.27.0
ifaddr==0.2.0
Jinja2==3.1.3
lru-dict==1.3.0
mutagen==1.47.0
-orjson==3.10.1
+orjson==3.9.15
packaging>=23.1
paho-mqtt==1.6.1
Pillow==10.3.0
diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py
index e78398ebf03..e282ced90ac 100644
--- a/homeassistant/requirements.py
+++ b/homeassistant/requirements.py
@@ -122,6 +122,11 @@ def _install_requirements_if_missing(
return installed, failures
+def _set_result_unless_done(future: asyncio.Future[None]) -> None:
+ if not future.done():
+ future.set_result(None)
+
+
class RequirementsManager:
"""Manage requirements."""
@@ -144,16 +149,13 @@ class RequirementsManager:
is invalid, RequirementNotFound if there was some type of
failure to install requirements.
"""
-
if done is None:
done = {domain}
else:
done.add(domain)
- integration = await async_get_integration(self.hass, domain)
-
if self.hass.config.skip_pip:
- return integration
+ return await async_get_integration(self.hass, domain)
cache = self.integrations_with_reqs
int_or_fut = cache.get(domain, UNDEFINED)
@@ -170,19 +172,19 @@ class RequirementsManager:
if int_or_fut is not UNDEFINED:
return cast(Integration, int_or_fut)
- event = cache[domain] = self.hass.loop.create_future()
+ future = cache[domain] = self.hass.loop.create_future()
try:
+ integration = await async_get_integration(self.hass, domain)
await self._async_process_integration(integration, done)
except Exception:
del cache[domain]
- if not event.done():
- event.set_result(None)
raise
+ finally:
+ _set_result_unless_done(future)
cache[domain] = integration
- if not event.done():
- event.set_result(None)
+ _set_result_unless_done(future)
return integration
async def _async_process_integration(
diff --git a/homeassistant/runner.py b/homeassistant/runner.py
index f036c7d6322..4e2326d4ea7 100644
--- a/homeassistant/runner.py
+++ b/homeassistant/runner.py
@@ -107,6 +107,7 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
def new_event_loop(self) -> asyncio.AbstractEventLoop:
"""Get the event loop."""
loop: asyncio.AbstractEventLoop = super().new_event_loop()
+ setattr(loop, "_thread_ident", threading.get_ident())
loop.set_exception_handler(_async_loop_exception_handler)
if self.debug:
loop.set_debug(True)
diff --git a/homeassistant/setup.py b/homeassistant/setup.py
index 5772fce6955..fab70e31d9d 100644
--- a/homeassistant/setup.py
+++ b/homeassistant/setup.py
@@ -459,7 +459,9 @@ async def _async_setup_component(
# Cleanup
hass.data[DATA_SETUP].pop(domain, None)
- hass.bus.async_fire(EVENT_COMPONENT_LOADED, EventComponentLoaded(component=domain))
+ hass.bus.async_fire_internal(
+ EVENT_COMPONENT_LOADED, EventComponentLoaded(component=domain)
+ )
return True
diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py
index 0cf9fc992c5..19c20207e1d 100644
--- a/homeassistant/util/async_.py
+++ b/homeassistant/util/async_.py
@@ -52,8 +52,7 @@ def run_callback_threadsafe(
Return a concurrent.futures.Future to access the result.
"""
- ident = loop.__dict__.get("_thread_ident")
- if ident is not None and ident == threading.get_ident():
+ if (ident := loop.__dict__.get("_thread_ident")) and ident == threading.get_ident():
raise RuntimeError("Cannot be called from within the event loop")
future: concurrent.futures.Future[_T] = concurrent.futures.Future()
diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py
index 2f2b415144f..923838a48a5 100644
--- a/homeassistant/util/dt.py
+++ b/homeassistant/util/dt.py
@@ -286,36 +286,78 @@ def parse_time(time_str: str) -> dt.time | None:
return None
-def get_age(date: dt.datetime) -> str:
- """Take a datetime and return its "age" as a string.
-
- The age can be in second, minute, hour, day, month or year. Only the
- biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will
- be returned.
- Make sure date is not in the future, or else it won't work.
- """
+def _get_timestring(timediff: float, precision: int = 1) -> str:
+ """Return a string representation of a time diff."""
def formatn(number: int, unit: str) -> str:
"""Add "unit" if it's plural."""
if number == 1:
- return f"1 {unit}"
- return f"{number:d} {unit}s"
+ return f"1 {unit} "
+ return f"{number:d} {unit}s "
+
+ if timediff == 0.0:
+ return "0 seconds"
+
+ units = ("year", "month", "day", "hour", "minute", "second")
+
+ factors = (365 * 24 * 60 * 60, 30 * 24 * 60 * 60, 24 * 60 * 60, 60 * 60, 60, 1)
+
+ result_string: str = ""
+ current_precision = 0
+
+ for i, current_factor in enumerate(factors):
+ selected_unit = units[i]
+ if timediff < current_factor:
+ continue
+ current_precision = current_precision + 1
+ if current_precision == precision:
+ return (
+ result_string + formatn(round(timediff / current_factor), selected_unit)
+ ).rstrip()
+ curr_diff = int(timediff // current_factor)
+ result_string += formatn(curr_diff, selected_unit)
+ timediff -= (curr_diff) * current_factor
+
+ return result_string.rstrip()
+
+
+def get_age(date: dt.datetime, precision: int = 1) -> str:
+ """Take a datetime and return its "age" as a string.
+
+ The age can be in second, minute, hour, day, month and year.
+
+ depth number of units will be returned, with the last unit rounded
+
+ The date must be in the past or a ValueException will be raised.
+ """
delta = (now() - date).total_seconds()
+
rounded_delta = round(delta)
- units = ["second", "minute", "hour", "day", "month"]
- factors = [60, 60, 24, 30, 12]
- selected_unit = "year"
+ if rounded_delta < 0:
+ raise ValueError("Time value is in the future")
+ return _get_timestring(rounded_delta, precision)
- for i, next_factor in enumerate(factors):
- if rounded_delta < next_factor:
- selected_unit = units[i]
- break
- delta /= next_factor
- rounded_delta = round(delta)
- return formatn(rounded_delta, selected_unit)
+def get_time_remaining(date: dt.datetime, precision: int = 1) -> str:
+ """Take a datetime and return its "age" as a string.
+
+ The age can be in second, minute, hour, day, month and year.
+
+ depth number of units will be returned, with the last unit rounded
+
+ The date must be in the future or a ValueException will be raised.
+ """
+
+ delta = (date - now()).total_seconds()
+
+ rounded_delta = round(delta)
+
+ if rounded_delta < 0:
+ raise ValueError("Time value is in the past")
+
+ return _get_timestring(rounded_delta, precision)
def parse_time_expression(parameter: Any, min_value: int, max_value: int) -> list[int]:
diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py
index 8709186face..ab163578846 100644
--- a/homeassistant/util/logging.py
+++ b/homeassistant/util/logging.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import asyncio
from collections.abc import Callable, Coroutine
from functools import partial, wraps
import inspect
@@ -12,7 +11,12 @@ import queue
import traceback
from typing import Any, TypeVar, TypeVarTuple, cast, overload
-from homeassistant.core import HomeAssistant, callback, is_callback
+from homeassistant.core import (
+ HassJobType,
+ HomeAssistant,
+ callback,
+ get_hassjob_callable_job_type,
+)
_T = TypeVar("_T")
_Ts = TypeVarTuple("_Ts")
@@ -129,34 +133,38 @@ def _callback_wrapper(
@overload
def catch_log_exception(
- func: Callable[[*_Ts], Coroutine[Any, Any, Any]], format_err: Callable[[*_Ts], Any]
+ func: Callable[[*_Ts], Coroutine[Any, Any, Any]],
+ format_err: Callable[[*_Ts], Any],
+ job_type: HassJobType | None = None,
) -> Callable[[*_Ts], Coroutine[Any, Any, None]]: ...
@overload
def catch_log_exception(
- func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any]
+ func: Callable[[*_Ts], Any],
+ format_err: Callable[[*_Ts], Any],
+ job_type: HassJobType | None = None,
) -> Callable[[*_Ts], None] | Callable[[*_Ts], Coroutine[Any, Any, None]]: ...
def catch_log_exception(
- func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any]
+ func: Callable[[*_Ts], Any],
+ format_err: Callable[[*_Ts], Any],
+ job_type: HassJobType | None = None,
) -> Callable[[*_Ts], None] | Callable[[*_Ts], Coroutine[Any, Any, None]]:
"""Decorate a function func to catch and log exceptions.
If func is a coroutine function, a coroutine function will be returned.
If func is a callback, a callback will be returned.
"""
- # Check for partials to properly determine if coroutine function
- check_func = func
- while isinstance(check_func, partial):
- check_func = check_func.func # type: ignore[unreachable] # false positive
+ if job_type is None:
+ job_type = get_hassjob_callable_job_type(func)
- if asyncio.iscoroutinefunction(check_func):
+ if job_type is HassJobType.Coroutinefunction:
async_func = cast(Callable[[*_Ts], Coroutine[Any, Any, None]], func)
return wraps(async_func)(partial(_async_wrapper, async_func, format_err)) # type: ignore[return-value]
- if is_callback(check_func):
+ if job_type is HassJobType.Callback:
return wraps(func)(partial(_callback_wrapper, func, format_err)) # type: ignore[return-value]
return wraps(func)(partial(_sync_wrapper, func, format_err)) # type: ignore[return-value]
diff --git a/homeassistant/util/uuid.py b/homeassistant/util/uuid.py
index d924eab934d..b7e9c2ae4f8 100644
--- a/homeassistant/util/uuid.py
+++ b/homeassistant/util/uuid.py
@@ -9,4 +9,4 @@ def random_uuid_hex() -> str:
This uuid should not be used for cryptographically secure
operations.
"""
- return "%032x" % getrandbits(32 * 4)
+ return f"{getrandbits(32 * 4):032x}"
diff --git a/mypy.ini b/mypy.ini
index 216d43322a4..611dd176fbf 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -2112,6 +2112,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.husqvarna_automower.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.hydrawise.*]
check_untyped_defs = true
disallow_incomplete_defs = true
diff --git a/pyproject.toml b/pyproject.toml
index 4b3b15f7bde..d3f2af6bbf9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
-version = "2024.5.0.dev0"
+version = "2024.6.0.dev0"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@@ -53,7 +53,7 @@ dependencies = [
"cryptography==42.0.5",
"Pillow==10.3.0",
"pyOpenSSL==24.1.0",
- "orjson==3.10.1",
+ "orjson==3.9.15",
"packaging>=23.1",
"pip>=21.3.1",
"psutil-home-assistant==0.0.1",
@@ -251,7 +251,7 @@ disable = [
"nested-min-max", # PLW3301
"pointless-statement", # B018
"raise-missing-from", # B904
- # "redefined-builtin", # A001, ruff is way more stricter, needs work
+ "redefined-builtin", # A001
"try-except-raise", # TRY302
"unused-argument", # ARG001, we don't use it
"unused-format-string-argument", #F507
@@ -659,10 +659,11 @@ filterwarnings = [
]
[tool.ruff]
-required-version = ">=0.3.7"
+required-version = ">=0.4.2"
[tool.ruff.lint]
select = [
+ "A001", # Variable {name} is shadowing a Python builtin
"B002", # Python does not support the unary prefix increment
"B005", # Using .strip() with multi-character strings is misleading
"B007", # Loop control variable {name} not used within loop body
@@ -704,6 +705,7 @@ select = [
"RUF006", # Store a reference to the return value of asyncio.create_task
"RUF013", # PEP 484 prohibits implicit Optional
"RUF018", # Avoid assignment expressions in assert statements
+ "RUF019", # Unnecessary key check before dictionary access
# "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up
"S102", # Use of exec detected
"S103", # bad-file-permissions
diff --git a/requirements.txt b/requirements.txt
index 34ee8237921..44c60aec07a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -28,7 +28,7 @@ PyJWT==2.8.0
cryptography==42.0.5
Pillow==10.3.0
pyOpenSSL==24.1.0
-orjson==3.10.1
+orjson==3.9.15
packaging>=23.1
pip>=21.3.1
psutil-home-assistant==0.0.1
diff --git a/requirements_all.txt b/requirements_all.txt
index b4c81ac30de..011b3b60d4f 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -45,7 +45,7 @@ Mastodon.py==1.8.1
Pillow==10.3.0
# homeassistant.components.plex
-PlexAPI==4.15.11
+PlexAPI==4.15.12
# homeassistant.components.progettihwsw
ProgettiHWSW==0.1.3
@@ -140,7 +140,7 @@ TwitterAPI==2.7.12
WSDiscovery==2.0.0
# homeassistant.components.accuweather
-accuweather==2.1.1
+accuweather==3.0.0
# homeassistant.components.adax
adax==0.4.0
@@ -204,7 +204,7 @@ aioaseko==0.1.1
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2024.3.4
+aioautomower==2024.4.4
# homeassistant.components.azure_devops
aioazuredevops==2.0.0
@@ -222,7 +222,7 @@ aiocomelit==0.9.0
aiodhcpwatcher==1.0.0
# homeassistant.components.dhcp
-aiodiscover==2.0.0
+aiodiscover==2.1.0
# homeassistant.components.dnsip
aiodns==3.2.0
@@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==24.1.0
+aioesphomeapi==24.3.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -318,7 +318,7 @@ aioopenexchangerates==0.4.0
aiooui==0.1.5
# homeassistant.components.pegel_online
-aiopegelonline==0.0.9
+aiopegelonline==0.0.10
# homeassistant.components.acmeda
aiopulse==0.4.4
@@ -367,6 +367,9 @@ aioskybell==22.7.0
# homeassistant.components.slimproto
aioslimproto==3.0.0
+# homeassistant.components.solaredge
+aiosolaredge==0.2.0
+
# homeassistant.components.steamist
aiosteamist==0.3.2
@@ -383,7 +386,7 @@ aiotankerkoenig==0.4.1
aiotractive==0.5.6
# homeassistant.components.unifi
-aiounifi==74
+aiounifi==76
# homeassistant.components.vlc_telnet
aiovlc==0.1.0
@@ -538,7 +541,7 @@ beautifulsoup4==4.12.3
# beewi-smartclim==0.0.10
# homeassistant.components.zha
-bellows==0.38.1
+bellows==0.38.2
# homeassistant.components.bmw_connected_drive
bimmer-connected[china]==0.14.6
@@ -576,10 +579,10 @@ bluemaestro-ble==0.2.3
# bluepy==1.3.0
# homeassistant.components.bluetooth
-bluetooth-adapters==0.18.0
+bluetooth-adapters==0.19.0
# homeassistant.components.bluetooth
-bluetooth-auto-recovery==1.4.1
+bluetooth-auto-recovery==1.4.2
# homeassistant.components.bluetooth
# homeassistant.components.ld2410_ble
@@ -694,7 +697,7 @@ debugpy==1.8.1
# decora==0.6
# homeassistant.components.ecovacs
-deebot-client==6.0.2
+deebot-client==7.1.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -735,7 +738,7 @@ dovado==0.4.1
dremel3dpy==2.1.1
# homeassistant.components.drop_connect
-dropmqttapi==1.0.2
+dropmqttapi==1.0.3
# homeassistant.components.dsmr
dsmr-parser==1.3.1
@@ -806,6 +809,9 @@ env-canada==0.6.0
# homeassistant.components.season
ephem==4.1.5
+# homeassistant.components.epic_games_store
+epicstore-api==0.1.7
+
# homeassistant.components.epion
epion==0.0.3
@@ -934,7 +940,7 @@ georss-qld-bushfire-alert-client==0.7
getmac==0.9.4
# homeassistant.components.gios
-gios==3.2.2
+gios==4.0.0
# homeassistant.components.gitter
gitterpy==0.1.7
@@ -946,7 +952,7 @@ glances-api==0.6.0
goalzero==0.2.2
# homeassistant.components.goodwe
-goodwe==0.2.32
+goodwe==0.3.2
# homeassistant.components.google_mail
# homeassistant.components.google_tasks
@@ -974,7 +980,7 @@ goslide-api==0.5.1
gotailwind==0.2.2
# homeassistant.components.govee_ble
-govee-ble==0.31.0
+govee-ble==0.31.2
# homeassistant.components.govee_light_local
govee-local-api==1.4.4
@@ -1020,7 +1026,7 @@ ha-av==10.1.1
ha-ffmpeg==3.2.0
# homeassistant.components.iotawatt
-ha-iotawattpy==0.1.1
+ha-iotawattpy==0.1.2
# homeassistant.components.philips_js
ha-philipsjs==3.1.1
@@ -1069,13 +1075,13 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.46
+holidays==0.47
# homeassistant.components.frontend
-home-assistant-frontend==20240404.2
+home-assistant-frontend==20240424.1
# homeassistant.components.conversation
-home-assistant-intents==2024.4.3
+home-assistant-intents==2024.4.24
# homeassistant.components.home_connect
homeconnect==0.7.2
@@ -1113,7 +1119,7 @@ ibmiotf==0.3.4
# homeassistant.components.google
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
-ical==7.0.3
+ical==8.0.0
# homeassistant.components.ping
icmplib==3.0
@@ -1329,7 +1335,7 @@ motionblindsble==0.0.9
motioneye-client==0.3.14
# homeassistant.components.bang_olufsen
-mozart-api==3.2.1.150.6
+mozart-api==3.4.1.8.5
# homeassistant.components.mullvad
mullvad-api==1.0.0
@@ -1362,7 +1368,7 @@ netdata==1.1.0
netmap==0.7.0.2
# homeassistant.components.nam
-nettigo-air-monitor==2.2.2
+nettigo-air-monitor==3.0.0
# homeassistant.components.neurio_energy
neurio==0.3.1
@@ -1377,7 +1383,7 @@ nextcloudmonitor==1.5.0
nextcord==2.6.0
# homeassistant.components.nextdns
-nextdns==2.1.0
+nextdns==3.0.0
# homeassistant.components.nibe_heatpump
nibe==2.8.0
@@ -1492,7 +1498,7 @@ orvibo==1.1.2
ourgroceries==1.5.4
# homeassistant.components.ovo_energy
-ovoenergy==1.3.1
+ovoenergy==2.0.0
# homeassistant.components.p1_monitor
p1monitor==3.0.0
@@ -1542,7 +1548,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
-plugwise==0.37.1
+plugwise==0.37.3
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1655,7 +1661,7 @@ pyEmby==1.9
pyHik==0.3.2
# homeassistant.components.rfxtrx
-pyRFXtrx==0.31.0
+pyRFXtrx==0.31.1
# homeassistant.components.sony_projector
pySDCP==1
@@ -1812,7 +1818,7 @@ pyevilgenius==2.0.0
pyezviz==0.2.1.2
# homeassistant.components.fibaro
-pyfibaro==0.7.7
+pyfibaro==0.7.8
# homeassistant.components.fido
pyfido==2.1.2
@@ -1833,7 +1839,7 @@ pyforked-daapd==0.1.14
pyfreedompro==1.1.0
# homeassistant.components.fritzbox
-pyfritzhome==0.6.10
+pyfritzhome==0.6.11
# homeassistant.components.ifttt
pyfttt==0.3
@@ -1941,7 +1947,7 @@ pylibrespot-java==0.1.1
pylitejet==0.6.2
# homeassistant.components.litterrobot
-pylitterbot==2023.4.11
+pylitterbot==2023.5.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.20.0
@@ -2090,7 +2096,7 @@ pyrecswitch==1.0.2
pyrepetierng==0.1.0
# homeassistant.components.risco
-pyrisco==0.6.0
+pyrisco==0.6.1
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.6
@@ -2574,9 +2580,6 @@ soco==0.30.3
# homeassistant.components.solaredge_local
solaredge-local==0.2.3
-# homeassistant.components.solaredge
-solaredge==0.0.2
-
# homeassistant.components.solax
solax==3.1.0
@@ -2734,7 +2737,7 @@ tololib==1.1.0
toonapi==0.3.0
# homeassistant.components.totalconnect
-total-connect-client==2023.2
+total-connect-client==2023.12.1
# homeassistant.components.tplink_lte
tp-connected==0.0.4
@@ -2869,7 +2872,7 @@ wirelesstagpy==0.8.1
wled==0.17.0
# homeassistant.components.wolflink
-wolf-comm==0.0.6
+wolf-comm==0.0.7
# homeassistant.components.wyoming
wyoming==1.5.3
@@ -2914,7 +2917,7 @@ yeelight==0.7.14
yeelightsunflower==0.0.10
# homeassistant.components.yolink
-yolink-api==0.4.2
+yolink-api==0.4.3
# homeassistant.components.youless
youless-api==1.0.1
@@ -2938,7 +2941,7 @@ zeroconf==0.132.2
zeversolar==0.3.1
# homeassistant.components.zha
-zha-quirks==0.0.114
+zha-quirks==0.0.115
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.12
@@ -2959,7 +2962,7 @@ zigpy-zigate==0.12.0
zigpy-znp==0.12.1
# homeassistant.components.zha
-zigpy==0.63.5
+zigpy==0.64.0
# homeassistant.components.zoneminder
zm-py==0.5.4
diff --git a/requirements_test.txt b/requirements_test.txt
index f13e0e6a36b..7fa9b3d8c89 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -8,15 +8,15 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
astroid==3.1.0
-coverage==7.4.4
+coverage==7.5.0
freezegun==1.4.0
mock-open==1.4.0
-mypy-dev==1.10.0a3
+mypy==1.10.0
pre-commit==3.7.0
pydantic==1.10.12
pylint==3.1.0
pylint-per-file-ignores==1.3.2
-pipdeptree==2.16.1
+pipdeptree==2.17.0
pytest-asyncio==0.23.6
pytest-aiohttp==1.0.5
pytest-cov==5.0.0
@@ -29,7 +29,7 @@ pytest-unordered==0.6.0
pytest-picked==0.5.0
pytest-xdist==3.5.0
pytest==8.1.1
-requests-mock==1.11.0
+requests-mock==1.12.1
respx==0.21.0
syrupy==4.6.1
tqdm==4.66.2
@@ -50,4 +50,4 @@ types-pytz==2024.1.0.20240203
types-PyYAML==6.0.12.20240311
types-requests==2.31.0.3
types-xmltodict==0.13.0.3
-uv==0.1.27
+uv==0.1.35
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 0ba05afc18c..fffc9d9b2c1 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -39,7 +39,7 @@ HATasmota==0.8.0
Pillow==10.3.0
# homeassistant.components.plex
-PlexAPI==4.15.11
+PlexAPI==4.15.12
# homeassistant.components.progettihwsw
ProgettiHWSW==0.1.3
@@ -119,7 +119,7 @@ Tami4EdgeAPI==2.1
WSDiscovery==2.0.0
# homeassistant.components.accuweather
-accuweather==2.1.1
+accuweather==3.0.0
# homeassistant.components.adax
adax==0.4.0
@@ -183,7 +183,7 @@ aioaseko==0.1.1
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2024.3.4
+aioautomower==2024.4.4
# homeassistant.components.azure_devops
aioazuredevops==2.0.0
@@ -201,7 +201,7 @@ aiocomelit==0.9.0
aiodhcpwatcher==1.0.0
# homeassistant.components.dhcp
-aiodiscover==2.0.0
+aiodiscover==2.1.0
# homeassistant.components.dnsip
aiodns==3.2.0
@@ -222,7 +222,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==24.1.0
+aioesphomeapi==24.3.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -291,7 +291,7 @@ aioopenexchangerates==0.4.0
aiooui==0.1.5
# homeassistant.components.pegel_online
-aiopegelonline==0.0.9
+aiopegelonline==0.0.10
# homeassistant.components.acmeda
aiopulse==0.4.4
@@ -340,6 +340,9 @@ aioskybell==22.7.0
# homeassistant.components.slimproto
aioslimproto==3.0.0
+# homeassistant.components.solaredge
+aiosolaredge==0.2.0
+
# homeassistant.components.steamist
aiosteamist==0.3.2
@@ -356,7 +359,7 @@ aiotankerkoenig==0.4.1
aiotractive==0.5.6
# homeassistant.components.unifi
-aiounifi==74
+aiounifi==76
# homeassistant.components.vlc_telnet
aiovlc==0.1.0
@@ -463,7 +466,7 @@ base36==0.1.1
beautifulsoup4==4.12.3
# homeassistant.components.zha
-bellows==0.38.1
+bellows==0.38.2
# homeassistant.components.bmw_connected_drive
bimmer-connected[china]==0.14.6
@@ -491,10 +494,10 @@ bluecurrent-api==1.2.3
bluemaestro-ble==0.2.3
# homeassistant.components.bluetooth
-bluetooth-adapters==0.18.0
+bluetooth-adapters==0.19.0
# homeassistant.components.bluetooth
-bluetooth-auto-recovery==1.4.1
+bluetooth-auto-recovery==1.4.2
# homeassistant.components.bluetooth
# homeassistant.components.ld2410_ble
@@ -572,7 +575,7 @@ dbus-fast==2.21.1
debugpy==1.8.1
# homeassistant.components.ecovacs
-deebot-client==6.0.2
+deebot-client==7.1.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -607,7 +610,7 @@ discovery30303==0.2.1
dremel3dpy==2.1.1
# homeassistant.components.drop_connect
-dropmqttapi==1.0.2
+dropmqttapi==1.0.3
# homeassistant.components.dsmr
dsmr-parser==1.3.1
@@ -660,6 +663,9 @@ env-canada==0.6.0
# homeassistant.components.season
ephem==4.1.5
+# homeassistant.components.epic_games_store
+epicstore-api==0.1.7
+
# homeassistant.components.epion
epion==0.0.3
@@ -766,7 +772,7 @@ georss-qld-bushfire-alert-client==0.7
getmac==0.9.4
# homeassistant.components.gios
-gios==3.2.2
+gios==4.0.0
# homeassistant.components.glances
glances-api==0.6.0
@@ -775,7 +781,7 @@ glances-api==0.6.0
goalzero==0.2.2
# homeassistant.components.goodwe
-goodwe==0.2.32
+goodwe==0.3.2
# homeassistant.components.google_mail
# homeassistant.components.google_tasks
@@ -797,7 +803,7 @@ googlemaps==2.5.1
gotailwind==0.2.2
# homeassistant.components.govee_ble
-govee-ble==0.31.0
+govee-ble==0.31.2
# homeassistant.components.govee_light_local
govee-local-api==1.4.4
@@ -834,7 +840,7 @@ ha-av==10.1.1
ha-ffmpeg==3.2.0
# homeassistant.components.iotawatt
-ha-iotawattpy==0.1.1
+ha-iotawattpy==0.1.2
# homeassistant.components.philips_js
ha-philipsjs==3.1.1
@@ -871,13 +877,13 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.46
+holidays==0.47
# homeassistant.components.frontend
-home-assistant-frontend==20240404.2
+home-assistant-frontend==20240424.1
# homeassistant.components.conversation
-home-assistant-intents==2024.4.3
+home-assistant-intents==2024.4.24
# homeassistant.components.home_connect
homeconnect==0.7.2
@@ -906,7 +912,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.google
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
-ical==7.0.3
+ical==8.0.0
# homeassistant.components.ping
icmplib==3.0
@@ -1071,7 +1077,7 @@ motionblindsble==0.0.9
motioneye-client==0.3.14
# homeassistant.components.bang_olufsen
-mozart-api==3.2.1.150.6
+mozart-api==3.4.1.8.5
# homeassistant.components.mullvad
mullvad-api==1.0.0
@@ -1098,7 +1104,7 @@ nessclient==1.0.0
netmap==0.7.0.2
# homeassistant.components.nam
-nettigo-air-monitor==2.2.2
+nettigo-air-monitor==3.0.0
# homeassistant.components.nexia
nexia==2.0.8
@@ -1110,7 +1116,7 @@ nextcloudmonitor==1.5.0
nextcord==2.6.0
# homeassistant.components.nextdns
-nextdns==2.1.0
+nextdns==3.0.0
# homeassistant.components.nibe_heatpump
nibe==2.8.0
@@ -1186,7 +1192,7 @@ oralb-ble==0.17.6
ourgroceries==1.5.4
# homeassistant.components.ovo_energy
-ovoenergy==1.3.1
+ovoenergy==2.0.0
# homeassistant.components.p1_monitor
p1monitor==3.0.0
@@ -1219,7 +1225,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
-plugwise==0.37.1
+plugwise==0.37.3
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1305,7 +1311,7 @@ pyDuotecno==2024.3.2
pyElectra==1.2.0
# homeassistant.components.rfxtrx
-pyRFXtrx==0.31.0
+pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.28.2
@@ -1411,7 +1417,7 @@ pyevilgenius==2.0.0
pyezviz==0.2.1.2
# homeassistant.components.fibaro
-pyfibaro==0.7.7
+pyfibaro==0.7.8
# homeassistant.components.fido
pyfido==2.1.2
@@ -1429,7 +1435,7 @@ pyforked-daapd==0.1.14
pyfreedompro==1.1.0
# homeassistant.components.fritzbox
-pyfritzhome==0.6.10
+pyfritzhome==0.6.11
# homeassistant.components.ifttt
pyfttt==0.3
@@ -1516,7 +1522,7 @@ pylibrespot-java==0.1.1
pylitejet==0.6.2
# homeassistant.components.litterrobot
-pylitterbot==2023.4.11
+pylitterbot==2023.5.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.20.0
@@ -1632,7 +1638,7 @@ pyqwikswitch==0.93
pyrainbird==4.0.2
# homeassistant.components.risco
-pyrisco==0.6.0
+pyrisco==0.6.1
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.6
@@ -1990,9 +1996,6 @@ snapcast==2.3.6
# homeassistant.components.sonos
soco==0.30.3
-# homeassistant.components.solaredge
-solaredge==0.0.2
-
# homeassistant.components.solax
solax==3.1.0
@@ -2111,7 +2114,7 @@ tololib==1.1.0
toonapi==0.3.0
# homeassistant.components.totalconnect
-total-connect-client==2023.2
+total-connect-client==2023.12.1
# homeassistant.components.tplink_omada
tplink-omada-client==1.3.12
@@ -2225,7 +2228,7 @@ wiffi==1.1.2
wled==0.17.0
# homeassistant.components.wolflink
-wolf-comm==0.0.6
+wolf-comm==0.0.7
# homeassistant.components.wyoming
wyoming==1.5.3
@@ -2264,7 +2267,7 @@ yalexs==3.0.1
yeelight==0.7.14
# homeassistant.components.yolink
-yolink-api==0.4.2
+yolink-api==0.4.3
# homeassistant.components.youless
youless-api==1.0.1
@@ -2285,7 +2288,7 @@ zeroconf==0.132.2
zeversolar==0.3.1
# homeassistant.components.zha
-zha-quirks==0.0.114
+zha-quirks==0.0.115
# homeassistant.components.zha
zigpy-deconz==0.23.1
@@ -2300,7 +2303,7 @@ zigpy-zigate==0.12.0
zigpy-znp==0.12.1
# homeassistant.components.zha
-zigpy==0.63.5
+zigpy==0.64.0
# homeassistant.components.zwave_js
zwave-js-server-python==0.55.3
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index 46ade953da2..05e98a945d2 100644
--- a/requirements_test_pre_commit.txt
+++ b/requirements_test_pre_commit.txt
@@ -1,5 +1,5 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.2.6
-ruff==0.3.7
+ruff==0.4.2
yamllint==1.35.1
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index 7fc0907e756..a5db9997d9d 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -17,7 +17,10 @@ from typing import Any
from homeassistant.util.yaml.loader import load_yaml
from script.hassfest.model import Integration
-COMMENT_REQUIREMENTS = (
+# Requirements which can't be installed on all systems because they rely on additional
+# system packages. Requirements listed in EXCLUDED_REQUIREMENTS_ALL will be commented-out
+# in requirements_all.txt and requirements_test_all.txt.
+EXCLUDED_REQUIREMENTS_ALL = {
"atenpdu", # depends on pysnmp which is not maintained at this time
"avea", # depends on bluepy
"avion",
@@ -36,10 +39,39 @@ COMMENT_REQUIREMENTS = (
"pyuserinput",
"tensorflow",
"tf-models-official",
-)
+}
-COMMENT_REQUIREMENTS_NORMALIZED = {
- commented.lower().replace("_", "-") for commented in COMMENT_REQUIREMENTS
+# Requirements excluded by EXCLUDED_REQUIREMENTS_ALL which should be included when
+# building integration wheels for all architectures.
+INCLUDED_REQUIREMENTS_WHEELS = {
+ "decora-wifi",
+ "evdev",
+ "pycups",
+ "python-gammu",
+ "pyuserinput",
+}
+
+
+# Requirements to exclude or include when running github actions.
+# Requirements listed in "exclude" will be commented-out in
+# requirements_all_{action}.txt
+# Requirements listed in "include" must be listed in EXCLUDED_REQUIREMENTS_CI, and
+# will be included in requirements_all_{action}.txt
+
+OVERRIDDEN_REQUIREMENTS_ACTIONS = {
+ "pytest": {"exclude": set(), "include": {"python-gammu"}},
+ "wheels_aarch64": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS},
+ # Pandas has issues building on armhf, it is expected they
+ # will drop the platform in the near future (they consider it
+ # "flimsy" on 386). The following packages depend on pandas,
+ # so we comment them out.
+ "wheels_armhf": {
+ "exclude": {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"},
+ "include": INCLUDED_REQUIREMENTS_WHEELS,
+ },
+ "wheels_armv7": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS},
+ "wheels_amd64": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS},
+ "wheels_i386": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS},
}
IGNORE_PIN = ("colorlog>2.1,<3", "urllib3")
@@ -254,6 +286,12 @@ def gather_recursive_requirements(
return reqs
+def _normalize_package_name(package_name: str) -> str:
+ """Normalize a package name."""
+ # pipdeptree needs lowercase and dash instead of underscore or period as separator
+ return package_name.lower().replace("_", "-").replace(".", "-")
+
+
def normalize_package_name(requirement: str) -> str:
"""Return a normalized package name from a requirement string."""
# This function is also used in hassfest.
@@ -262,12 +300,24 @@ def normalize_package_name(requirement: str) -> str:
return ""
# pipdeptree needs lowercase and dash instead of underscore or period as separator
- return match.group(1).lower().replace("_", "-").replace(".", "-")
+ return _normalize_package_name(match.group(1))
def comment_requirement(req: str) -> bool:
"""Comment out requirement. Some don't install on all systems."""
- return normalize_package_name(req) in COMMENT_REQUIREMENTS_NORMALIZED
+ return normalize_package_name(req) in EXCLUDED_REQUIREMENTS_ALL
+
+
+def process_action_requirement(req: str, action: str) -> str:
+ """Process requirement for a specific github action."""
+ normalized_package_name = normalize_package_name(req)
+ if normalized_package_name in OVERRIDDEN_REQUIREMENTS_ACTIONS[action]["exclude"]:
+ return f"# {req}"
+ if normalized_package_name in OVERRIDDEN_REQUIREMENTS_ACTIONS[action]["include"]:
+ return req
+ if normalized_package_name in EXCLUDED_REQUIREMENTS_ALL:
+ return f"# {req}"
+ return req
def gather_modules() -> dict[str, list[str]] | None:
@@ -353,6 +403,16 @@ def generate_requirements_list(reqs: dict[str, list[str]]) -> str:
return "".join(output)
+def generate_action_requirements_list(reqs: dict[str, list[str]], action: str) -> str:
+ """Generate a pip file based on requirements."""
+ output = []
+ for pkg, requirements in sorted(reqs.items(), key=itemgetter(0)):
+ output.extend(f"\n# {req}" for req in sorted(requirements))
+ processed_pkg = process_action_requirement(pkg, action)
+ output.append(f"\n{processed_pkg}\n")
+ return "".join(output)
+
+
def requirements_output() -> str:
"""Generate output for requirements."""
output = [
@@ -379,6 +439,18 @@ def requirements_all_output(reqs: dict[str, list[str]]) -> str:
return "".join(output)
+def requirements_all_action_output(reqs: dict[str, list[str]], action: str) -> str:
+ """Generate output for requirements_all_{action}."""
+ output = [
+ f"# Home Assistant Core, full dependency set for {action}\n",
+ GENERATED_MESSAGE,
+ "-r requirements.txt\n",
+ ]
+ output.append(generate_action_requirements_list(reqs, action))
+
+ return "".join(output)
+
+
def requirements_test_all_output(reqs: dict[str, list[str]]) -> str:
"""Generate output for test_requirements."""
output = [
@@ -459,7 +531,7 @@ def diff_file(filename: str, content: str) -> list[str]:
)
-def main(validate: bool) -> int:
+def main(validate: bool, ci: bool) -> int:
"""Run the script."""
if not os.path.isfile("requirements_all.txt"):
print("Run this from HA root dir")
@@ -472,17 +544,28 @@ def main(validate: bool) -> int:
reqs_file = requirements_output()
reqs_all_file = requirements_all_output(data)
+ reqs_all_action_files = {
+ action: requirements_all_action_output(data, action)
+ for action in OVERRIDDEN_REQUIREMENTS_ACTIONS
+ }
reqs_test_all_file = requirements_test_all_output(data)
+ # Always calling requirements_pre_commit_output is intentional to ensure
+ # the code is called by the pre-commit hooks.
reqs_pre_commit_file = requirements_pre_commit_output()
constraints = gather_constraints()
- files = (
+ files = [
("requirements.txt", reqs_file),
("requirements_all.txt", reqs_all_file),
("requirements_test_pre_commit.txt", reqs_pre_commit_file),
("requirements_test_all.txt", reqs_test_all_file),
("homeassistant/package_constraints.txt", constraints),
- )
+ ]
+ if ci:
+ files.extend(
+ (f"requirements_all_{action}.txt", reqs_all_file)
+ for action, reqs_all_file in reqs_all_action_files.items()
+ )
if validate:
errors = []
@@ -511,4 +594,5 @@ def main(validate: bool) -> int:
if __name__ == "__main__":
_VAL = sys.argv[-1] == "validate"
- sys.exit(main(_VAL))
+ _CI = sys.argv[-1] == "ci"
+ sys.exit(main(_VAL, _CI))
diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py
index 6fe7700cb3f..66796d4dd0d 100644
--- a/script/hassfest/dependencies.py
+++ b/script/hassfest/dependencies.py
@@ -32,7 +32,11 @@ class ImportCollector(ast.NodeVisitor):
self._cur_fil_dir = fil.relative_to(self.integration.path)
self.referenced[self._cur_fil_dir] = set()
- self.visit(ast.parse(fil.read_text()))
+ try:
+ self.visit(ast.parse(fil.read_text()))
+ except SyntaxError as e:
+ e.add_note(f"File: {fil}")
+ raise
self._cur_fil_dir = None
def _add_reference(self, reference_domain: str) -> None:
@@ -148,10 +152,12 @@ IGNORE_VIOLATIONS = {
("demo", "manual"),
# This would be a circular dep
("http", "network"),
+ ("http", "cloud"),
# This would be a circular dep
("zha", "homeassistant_hardware"),
("zha", "homeassistant_sky_connect"),
("zha", "homeassistant_yellow"),
+ ("homeassistant_sky_connect", "zha"),
# This should become a helper method that integrations can submit data to
("websocket_api", "lovelace"),
("websocket_api", "shopping_list"),
diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py
index ee63bf07f90..2c4ed47b158 100644
--- a/script/hassfest/requirements.py
+++ b/script/hassfest/requirements.py
@@ -15,13 +15,13 @@ from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
from tqdm import tqdm
import homeassistant.util.package as pkg_util
-from script.gen_requirements_all import COMMENT_REQUIREMENTS, normalize_package_name
+from script.gen_requirements_all import (
+ EXCLUDED_REQUIREMENTS_ALL,
+ normalize_package_name,
+)
from .model import Config, Integration
-IGNORE_PACKAGES = {
- commented.lower().replace("_", "-") for commented in COMMENT_REQUIREMENTS
-}
PACKAGE_REGEX = re.compile(
r"^(?:--.+\s)?([-_,\.\w\d\[\]]+)(==|>=|<=|~=|!=|<|>|===)*(.*)$"
)
@@ -116,7 +116,7 @@ def validate_requirements(integration: Integration) -> None:
f"Failed to normalize package name from requirement {req}",
)
return
- if package in IGNORE_PACKAGES:
+ if package in EXCLUDED_REQUIREMENTS_ALL:
continue
integration_requirements.add(req)
integration_packages.add(package)
diff --git a/tests/common.py b/tests/common.py
index b12f0ed37da..7bb16ce5c54 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -22,6 +22,7 @@ from unittest.mock import AsyncMock, Mock, patch
from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401
import pytest
+from syrupy import SnapshotAssertion
import voluptuous as vol
from homeassistant import auth, bootstrap, config_entries, loader
@@ -448,10 +449,11 @@ def async_fire_mqtt_message(
msg.payload = payload
msg.qos = qos
msg.retain = retain
+ msg.timestamp = time.monotonic()
mqtt_data: MqttData = hass.data["mqtt"]
assert mqtt_data.client
- mqtt_data.client._mqtt_handle_message(msg)
+ mqtt_data.client._async_mqtt_on_message(Mock(), None, msg)
fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message)
@@ -1733,3 +1735,22 @@ def setup_test_component_platform(
mock_platform(hass, f"test.{domain}", platform, built_in=built_in)
return platform
+
+
+async def snapshot_platform(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+ config_entry_id: str,
+) -> None:
+ """Snapshot a platform."""
+ entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id)
+ assert entity_entries
+ assert (
+ len({entity_entry.domain for entity_entry in entity_entries}) == 1
+ ), "Please limit the loaded platforms to 1 platform."
+ for entity_entry in entity_entries:
+ assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")
+ assert entity_entry.disabled_by is None, "Please enable all entities."
+ assert (state := hass.states.get(entity_entry.entity_id))
+ assert state == snapshot(name=f"{entity_entry.entity_id}-state")
diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr
index 081e7bf595a..1542d22aa7b 100644
--- a/tests/components/accuweather/snapshots/test_weather.ambr
+++ b/tests/components/accuweather/snapshots/test_weather.ambr
@@ -1,158 +1,4 @@
# serializer version: 1
-# name: test_forecast_service
- dict({
- 'forecast': list([
- dict({
- 'apparent_temperature': 29.8,
- 'cloud_coverage': 58,
- 'condition': 'lightning-rainy',
- 'datetime': '2020-07-26T05:00:00+00:00',
- 'precipitation': 2.5,
- 'precipitation_probability': 60,
- 'temperature': 29.5,
- 'templow': 15.4,
- 'uv_index': 5,
- 'wind_bearing': 166,
- 'wind_gust_speed': 29.6,
- 'wind_speed': 13.0,
- }),
- dict({
- 'apparent_temperature': 28.9,
- 'cloud_coverage': 52,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-27T05:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 25,
- 'temperature': 26.2,
- 'templow': 15.9,
- 'uv_index': 7,
- 'wind_bearing': 297,
- 'wind_gust_speed': 14.8,
- 'wind_speed': 9.3,
- }),
- dict({
- 'apparent_temperature': 31.6,
- 'cloud_coverage': 65,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-28T05:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 10,
- 'temperature': 31.7,
- 'templow': 16.8,
- 'uv_index': 7,
- 'wind_bearing': 198,
- 'wind_gust_speed': 24.1,
- 'wind_speed': 16.7,
- }),
- dict({
- 'apparent_temperature': 26.5,
- 'cloud_coverage': 45,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-29T05:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 9,
- 'temperature': 24.0,
- 'templow': 11.7,
- 'uv_index': 6,
- 'wind_bearing': 293,
- 'wind_gust_speed': 24.1,
- 'wind_speed': 13.0,
- }),
- dict({
- 'apparent_temperature': 22.2,
- 'cloud_coverage': 50,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-30T05:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 1,
- 'temperature': 21.4,
- 'templow': 12.2,
- 'uv_index': 7,
- 'wind_bearing': 280,
- 'wind_gust_speed': 27.8,
- 'wind_speed': 18.5,
- }),
- ]),
- })
-# ---
-# name: test_forecast_service[forecast]
- dict({
- 'weather.home': dict({
- 'forecast': list([
- dict({
- 'apparent_temperature': 29.8,
- 'cloud_coverage': 58,
- 'condition': 'lightning-rainy',
- 'datetime': '2020-07-26T05:00:00+00:00',
- 'precipitation': 2.5,
- 'precipitation_probability': 60,
- 'temperature': 29.5,
- 'templow': 15.4,
- 'uv_index': 5,
- 'wind_bearing': 166,
- 'wind_gust_speed': 29.6,
- 'wind_speed': 13.0,
- }),
- dict({
- 'apparent_temperature': 28.9,
- 'cloud_coverage': 52,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-27T05:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 25,
- 'temperature': 26.2,
- 'templow': 15.9,
- 'uv_index': 7,
- 'wind_bearing': 297,
- 'wind_gust_speed': 14.8,
- 'wind_speed': 9.3,
- }),
- dict({
- 'apparent_temperature': 31.6,
- 'cloud_coverage': 65,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-28T05:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 10,
- 'temperature': 31.7,
- 'templow': 16.8,
- 'uv_index': 7,
- 'wind_bearing': 198,
- 'wind_gust_speed': 24.1,
- 'wind_speed': 16.7,
- }),
- dict({
- 'apparent_temperature': 26.5,
- 'cloud_coverage': 45,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-29T05:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 9,
- 'temperature': 24.0,
- 'templow': 11.7,
- 'uv_index': 6,
- 'wind_bearing': 293,
- 'wind_gust_speed': 24.1,
- 'wind_speed': 13.0,
- }),
- dict({
- 'apparent_temperature': 22.2,
- 'cloud_coverage': 50,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-30T05:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 1,
- 'temperature': 21.4,
- 'templow': 12.2,
- 'uv_index': 7,
- 'wind_bearing': 280,
- 'wind_gust_speed': 27.8,
- 'wind_speed': 18.5,
- }),
- ]),
- }),
- })
-# ---
# name: test_forecast_service[get_forecast]
dict({
'forecast': list([
@@ -455,3 +301,67 @@
}),
])
# ---
+# name: test_weather[weather.home-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'weather',
+ 'entity_category': None,
+ 'entity_id': 'weather.home',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'accuweather',
+ 'previous_unique_id': None,
+ 'supported_features': ,
+ 'translation_key': None,
+ 'unique_id': '0123456',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_weather[weather.home-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'apparent_temperature': 22.8,
+ 'attribution': 'Data provided by AccuWeather',
+ 'cloud_coverage': 10,
+ 'dew_point': 16.2,
+ 'friendly_name': 'Home',
+ 'humidity': 67,
+ 'precipitation_unit': ,
+ 'pressure': 1012.0,
+ 'pressure_unit': ,
+ 'supported_features': ,
+ 'temperature': 22.6,
+ 'temperature_unit': ,
+ 'uv_index': 6,
+ 'visibility': 16.1,
+ 'visibility_unit': ,
+ 'wind_bearing': 180,
+ 'wind_gust_speed': 20.3,
+ 'wind_speed': 14.5,
+ 'wind_speed_unit': ,
+ }),
+ 'context': ,
+ 'entity_id': 'weather.home',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'sunny',
+ })
+# ---
diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py
index e79e49db96d..127e4d74cd8 100644
--- a/tests/components/accuweather/test_sensor.py
+++ b/tests/components/accuweather/test_sensor.py
@@ -30,6 +30,7 @@ from tests.common import (
async_fire_time_changed,
load_json_array_fixture,
load_json_object_fixture,
+ snapshot_platform,
)
@@ -42,14 +43,7 @@ async def test_sensor(
"""Test states of the sensor."""
with patch("homeassistant.components.accuweather.PLATFORMS", [Platform.SENSOR]):
entry = await init_integration(hass)
-
- entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
-
- assert entity_entries
- for entity_entry in entity_entries:
- assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")
- assert (state := hass.states.get(entity_entry.entity_id))
- assert state == snapshot(name=f"{entity_entry.entity_id}-state")
+ await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
async def test_availability(hass: HomeAssistant) -> None:
diff --git a/tests/components/accuweather/test_system_health.py b/tests/components/accuweather/test_system_health.py
index 6321071eaa5..562c572c830 100644
--- a/tests/components/accuweather/test_system_health.py
+++ b/tests/components/accuweather/test_system_health.py
@@ -5,6 +5,7 @@ from unittest.mock import Mock
from aiohttp import ClientError
+from homeassistant.components.accuweather import AccuWeatherData
from homeassistant.components.accuweather.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -23,8 +24,10 @@ async def test_accuweather_system_health(
await hass.async_block_till_done()
hass.data[DOMAIN] = {}
- hass.data[DOMAIN]["0123xyz"] = {}
- hass.data[DOMAIN]["0123xyz"] = Mock(accuweather=Mock(requests_remaining="42"))
+ hass.data[DOMAIN]["0123xyz"] = AccuWeatherData(
+ coordinator_observation=Mock(accuweather=Mock(requests_remaining="42")),
+ coordinator_daily_forecast=Mock(),
+ )
info = await get_system_health_info(hass, DOMAIN)
@@ -48,8 +51,10 @@ async def test_accuweather_system_health_fail(
await hass.async_block_till_done()
hass.data[DOMAIN] = {}
- hass.data[DOMAIN]["0123xyz"] = {}
- hass.data[DOMAIN]["0123xyz"] = Mock(accuweather=Mock(requests_remaining="0"))
+ hass.data[DOMAIN]["0123xyz"] = AccuWeatherData(
+ coordinator_observation=Mock(accuweather=Mock(requests_remaining="0")),
+ coordinator_daily_forecast=Mock(),
+ )
info = await get_system_health_info(hass, DOMAIN)
diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py
index b3237ca2958..d97a5d3da3c 100644
--- a/tests/components/accuweather/test_weather.py
+++ b/tests/components/accuweather/test_weather.py
@@ -7,34 +7,14 @@ from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.accuweather.const import (
- ATTRIBUTION,
- UPDATE_INTERVAL_DAILY_FORECAST,
-)
+from homeassistant.components.accuweather.const import UPDATE_INTERVAL_DAILY_FORECAST
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
- ATTR_WEATHER_APPARENT_TEMPERATURE,
- ATTR_WEATHER_CLOUD_COVERAGE,
- ATTR_WEATHER_DEW_POINT,
- ATTR_WEATHER_HUMIDITY,
- ATTR_WEATHER_PRESSURE,
- ATTR_WEATHER_TEMPERATURE,
- ATTR_WEATHER_UV_INDEX,
- ATTR_WEATHER_VISIBILITY,
- ATTR_WEATHER_WIND_BEARING,
- ATTR_WEATHER_WIND_GUST_SPEED,
- ATTR_WEATHER_WIND_SPEED,
DOMAIN as WEATHER_DOMAIN,
LEGACY_SERVICE_GET_FORECAST,
SERVICE_GET_FORECASTS,
- WeatherEntityFeature,
-)
-from homeassistant.const import (
- ATTR_ATTRIBUTION,
- ATTR_ENTITY_ID,
- ATTR_SUPPORTED_FEATURES,
- STATE_UNAVAILABLE,
)
+from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
@@ -46,37 +26,18 @@ from tests.common import (
async_fire_time_changed,
load_json_array_fixture,
load_json_object_fixture,
+ snapshot_platform,
)
from tests.typing import WebSocketGenerator
-async def test_weather(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None:
+async def test_weather(
+ hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
+) -> None:
"""Test states of the weather without forecast."""
- await init_integration(hass)
-
- state = hass.states.get("weather.home")
- assert state
- assert state.state == "sunny"
- assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 67
- assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1012.0
- assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == 22.6
- assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1
- assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 180
- assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 14.5 # 4.03 m/s -> km/h
- assert state.attributes.get(ATTR_WEATHER_APPARENT_TEMPERATURE) == 22.8
- assert state.attributes.get(ATTR_WEATHER_DEW_POINT) == 16.2
- assert state.attributes.get(ATTR_WEATHER_CLOUD_COVERAGE) == 10
- assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3
- assert state.attributes.get(ATTR_WEATHER_UV_INDEX) == 6
- assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
- assert (
- state.attributes.get(ATTR_SUPPORTED_FEATURES)
- is WeatherEntityFeature.FORECAST_DAILY
- )
-
- entry = entity_registry.async_get("weather.home")
- assert entry
- assert entry.unique_id == "0123456"
+ with patch("homeassistant.components.accuweather.PLATFORMS", [Platform.WEATHER]):
+ entry = await init_integration(hass)
+ await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
async def test_availability(hass: HomeAssistant) -> None:
diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py
index 1010a45b8fb..db4400f85d3 100644
--- a/tests/components/airnow/conftest.py
+++ b/tests/components/airnow/conftest.py
@@ -44,7 +44,7 @@ def options_fixture(hass):
}
-@pytest.fixture(name="data", scope="session")
+@pytest.fixture(name="data", scope="package")
def data_fixture():
"""Define a fixture for response data."""
return json.loads(load_fixture("response.json", "airnow"))
diff --git a/tests/components/airvisual_pro/conftest.py b/tests/components/airvisual_pro/conftest.py
index 719b25b3cdf..c90eb432c25 100644
--- a/tests/components/airvisual_pro/conftest.py
+++ b/tests/components/airvisual_pro/conftest.py
@@ -56,7 +56,7 @@ def disconnect_fixture():
return AsyncMock()
-@pytest.fixture(name="data", scope="session")
+@pytest.fixture(name="data", scope="package")
def data_fixture():
"""Define an update coordinator data example."""
return json.loads(load_fixture("data.json", "airvisual_pro"))
diff --git a/tests/components/alexa/test_common.py b/tests/components/alexa/test_common.py
index 0cc4d995efa..9fdcc1c89c1 100644
--- a/tests/components/alexa/test_common.py
+++ b/tests/components/alexa/test_common.py
@@ -158,14 +158,14 @@ async def assert_power_controller_works(
_, response = await assert_request_calls_service(
"Alexa.PowerController", "TurnOn", endpoint, on_service, hass
)
- for property in response["context"]["properties"]:
- assert property["timeOfSample"] == timestamp
+ for context_property in response["context"]["properties"]:
+ assert context_property["timeOfSample"] == timestamp
_, response = await assert_request_calls_service(
"Alexa.PowerController", "TurnOff", endpoint, off_service, hass
)
- for property in response["context"]["properties"]:
- assert property["timeOfSample"] == timestamp
+ for context_property in response["context"]["properties"]:
+ assert context_property["timeOfSample"] == timestamp
async def assert_scene_controller_works(
diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr
index 377018c54be..fadb15ad015 100644
--- a/tests/components/ambient_network/snapshots/test_sensor.ambr
+++ b/tests/components/ambient_network/snapshots/test_sensor.ambr
@@ -10,7 +10,7 @@
'config_entry_id': ,
'device_class': None,
'device_id': ,
- 'disabled_by': ,
+ 'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.station_a_absolute_pressure',
@@ -22,6 +22,9 @@
}),
'name': None,
'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 1,
+ }),
'sensor.private': dict({
'suggested_unit_of_measurement': ,
}),
@@ -38,7 +41,21 @@
})
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_absolute_pressure-state]
- None
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by ambientnetwork.net',
+ 'device_class': 'pressure',
+ 'friendly_name': 'Station A Absolute pressure',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.station_a_absolute_pressure',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '977.616536580043',
+ })
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_daily_rain-entry]
EntityRegistryEntrySnapshot({
@@ -332,7 +349,7 @@
'config_entry_id': ,
'device_class': None,
'device_id': ,
- 'disabled_by': ,
+ 'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.station_a_irradiance',
@@ -344,6 +361,9 @@
}),
'name': None,
'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 1,
+ }),
}),
'original_device_class': ,
'original_icon': None,
@@ -357,7 +377,21 @@
})
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_irradiance-state]
- None
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by ambientnetwork.net',
+ 'device_class': 'irradiance',
+ 'friendly_name': 'Station A Irradiance',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.station_a_irradiance',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '37.64',
+ })
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_last_rain-entry]
EntityRegistryEntrySnapshot({
@@ -368,7 +402,7 @@
'config_entry_id': ,
'device_class': None,
'device_id': ,
- 'disabled_by': ,
+ 'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.station_a_last_rain',
@@ -393,7 +427,19 @@
})
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_last_rain-state]
- None
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by ambientnetwork.net',
+ 'device_class': 'timestamp',
+ 'friendly_name': 'Station A Last rain',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.station_a_last_rain',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '2023-10-30T09:45:00+00:00',
+ })
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_max_daily_gust-entry]
EntityRegistryEntrySnapshot({
@@ -464,7 +510,7 @@
'config_entry_id': ,
'device_class': None,
'device_id': ,
- 'disabled_by': ,
+ 'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.station_a_monthly_rain',
@@ -476,6 +522,9 @@
}),
'name': None,
'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 1,
+ }),
'sensor.private': dict({
'suggested_unit_of_measurement': ,
}),
@@ -492,7 +541,21 @@
})
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_monthly_rain-state]
- None
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by ambientnetwork.net',
+ 'device_class': 'precipitation',
+ 'friendly_name': 'Station A Monthly rain',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.station_a_monthly_rain',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0.0',
+ })
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_relative_pressure-entry]
EntityRegistryEntrySnapshot({
@@ -672,7 +735,7 @@
'config_entry_id': ,
'device_class': None,
'device_id': ,
- 'disabled_by': ,
+ 'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.station_a_weekly_rain',
@@ -684,6 +747,9 @@
}),
'name': None,
'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 1,
+ }),
'sensor.private': dict({
'suggested_unit_of_measurement': ,
}),
@@ -700,7 +766,21 @@
})
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_weekly_rain-state]
- None
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by ambientnetwork.net',
+ 'device_class': 'precipitation',
+ 'friendly_name': 'Station A Weekly rain',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.station_a_weekly_rain',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0.0',
+ })
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_direction-entry]
EntityRegistryEntrySnapshot({
@@ -711,7 +791,7 @@
'config_entry_id': ,
'device_class': None,
'device_id': ,
- 'disabled_by': ,
+ 'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.station_a_wind_direction',
@@ -723,6 +803,9 @@
}),
'name': None,
'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 0,
+ }),
}),
'original_device_class': None,
'original_icon': None,
@@ -736,7 +819,19 @@
})
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_direction-state]
- None
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by ambientnetwork.net',
+ 'friendly_name': 'Station A Wind direction',
+ 'unit_of_measurement': '°',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.station_a_wind_direction',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '11',
+ })
# ---
# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_gust-entry]
EntityRegistryEntrySnapshot({
diff --git a/tests/components/ambient_network/test_sensor.py b/tests/components/ambient_network/test_sensor.py
index b556c0c9c7c..35aa90ffe05 100644
--- a/tests/components/ambient_network/test_sensor.py
+++ b/tests/components/ambient_network/test_sensor.py
@@ -14,11 +14,12 @@ from homeassistant.helpers import entity_registry as er
from .conftest import setup_platform
-from tests.common import async_fire_time_changed
+from tests.common import async_fire_time_changed, snapshot_platform
@freeze_time("2023-11-08")
@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True)
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors(
hass: HomeAssistant,
open_api: OpenAPI,
@@ -30,16 +31,7 @@ async def test_sensors(
"""Test all sensors under normal operation."""
await setup_platform(True, hass, config_entry)
- entity_entries = er.async_entries_for_config_entry(
- entity_registry, config_entry.entry_id
- )
-
- assert entity_entries
- for entity_entry in entity_entries:
- assert hass.states.get(entity_entry.entity_id) == snapshot(
- name=f"{entity_entry.entity_id}-state"
- )
- assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")
+ await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@freeze_time("2023-11-09")
diff --git a/tests/components/analytics_insights/test_sensor.py b/tests/components/analytics_insights/test_sensor.py
index e0850bbd55b..3ede971c8f8 100644
--- a/tests/components/analytics_insights/test_sensor.py
+++ b/tests/components/analytics_insights/test_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.helpers import entity_registry as er
from . import setup_integration
-from tests.common import MockConfigEntry, async_fire_time_changed
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_all_entities(
@@ -32,17 +32,10 @@ async def test_all_entities(
[Platform.SENSOR],
):
await setup_integration(hass, mock_config_entry)
- entity_entries = er.async_entries_for_config_entry(
- entity_registry, mock_config_entry.entry_id
+ await snapshot_platform(
+ hass, entity_registry, snapshot, mock_config_entry.entry_id
)
- assert entity_entries
- for entity_entry in entity_entries:
- assert hass.states.get(entity_entry.entity_id) == snapshot(
- name=f"{entity_entry.entity_id}-state"
- )
- assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")
-
async def test_connection_error(
hass: HomeAssistant,
diff --git a/tests/components/aosmith/snapshots/test_sensor.ambr b/tests/components/aosmith/snapshots/test_sensor.ambr
index 150e0c2934f..7aae9713037 100644
--- a/tests/components/aosmith/snapshots/test_sensor.ambr
+++ b/tests/components/aosmith/snapshots/test_sensor.ambr
@@ -1,5 +1,43 @@
# serializer version: 1
-# name: test_state[sensor.my_water_heater_energy_usage]
+# name: test_state[sensor.my_water_heater_energy_usage-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.my_water_heater_energy_usage',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 1,
+ }),
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Energy usage',
+ 'platform': 'aosmith',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'energy_usage',
+ 'unique_id': 'energy_usage_junctionId',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_state[sensor.my_water_heater_energy_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
@@ -15,7 +53,46 @@
'state': '132.825',
})
# ---
-# name: test_state[sensor.my_water_heater_hot_water_availability]
+# name: test_state[sensor.my_water_heater_hot_water_availability-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'low',
+ 'medium',
+ 'high',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.my_water_heater_hot_water_availability',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Hot water availability',
+ 'platform': 'aosmith',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'hot_water_availability',
+ 'unique_id': 'hot_water_availability_junctionId',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_state[sensor.my_water_heater_hot_water_availability-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr
index c3740341c17..deb079570f1 100644
--- a/tests/components/aosmith/snapshots/test_water_heater.ambr
+++ b/tests/components/aosmith/snapshots/test_water_heater.ambr
@@ -1,5 +1,103 @@
# serializer version: 1
-# name: test_state
+# name: test_state[False][water_heater.my_water_heater-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max_temp': 130,
+ 'min_temp': 95,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'water_heater',
+ 'entity_category': None,
+ 'entity_id': 'water_heater.my_water_heater',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'aosmith',
+ 'previous_unique_id': None,
+ 'supported_features': ,
+ 'translation_key': None,
+ 'unique_id': 'junctionId',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_state[False][water_heater.my_water_heater-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'away_mode': 'off',
+ 'current_temperature': None,
+ 'friendly_name': 'My water heater',
+ 'max_temp': 130,
+ 'min_temp': 95,
+ 'supported_features': ,
+ 'target_temp_high': None,
+ 'target_temp_low': None,
+ 'temperature': 130,
+ }),
+ 'context': ,
+ 'entity_id': 'water_heater.my_water_heater',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'electric',
+ })
+# ---
+# name: test_state[True][water_heater.my_water_heater-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max_temp': 130,
+ 'min_temp': 95,
+ 'operation_list': list([
+ 'electric',
+ 'eco',
+ 'heat_pump',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'water_heater',
+ 'entity_category': None,
+ 'entity_id': 'water_heater.my_water_heater',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'aosmith',
+ 'previous_unique_id': None,
+ 'supported_features': ,
+ 'translation_key': None,
+ 'unique_id': 'junctionId',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_state[True][water_heater.my_water_heater-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'away_mode': 'off',
@@ -26,24 +124,3 @@
'state': 'heat_pump',
})
# ---
-# name: test_state_non_heat_pump[False]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'away_mode': 'off',
- 'current_temperature': None,
- 'friendly_name': 'My water heater',
- 'max_temp': 130,
- 'min_temp': 95,
- 'supported_features': ,
- 'target_temp_high': None,
- 'target_temp_low': None,
- 'temperature': 130,
- }),
- 'context': ,
- 'entity_id': 'water_heater.my_water_heater',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'electric',
- })
-# ---
diff --git a/tests/components/aosmith/test_sensor.py b/tests/components/aosmith/test_sensor.py
index f94dfdb710c..d6acd8865d8 100644
--- a/tests/components/aosmith/test_sensor.py
+++ b/tests/components/aosmith/test_sensor.py
@@ -1,50 +1,30 @@
"""Tests for the sensor platform of the A. O. Smith integration."""
+from collections.abc import AsyncGenerator
+from unittest.mock import patch
+
import pytest
from syrupy.assertion import SnapshotAssertion
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from tests.common import MockConfigEntry
+from tests.common import MockConfigEntry, snapshot_platform
-@pytest.mark.parametrize(
- ("entity_id", "unique_id"),
- [
- (
- "sensor.my_water_heater_hot_water_availability",
- "hot_water_availability_junctionId",
- ),
- ("sensor.my_water_heater_energy_usage", "energy_usage_junctionId"),
- ],
-)
-async def test_setup(
- hass: HomeAssistant,
- entity_registry: er.EntityRegistry,
- init_integration: MockConfigEntry,
- entity_id: str,
- unique_id: str,
-) -> None:
- """Test the setup of the sensor entities."""
- entry = entity_registry.async_get(entity_id)
- assert entry
- assert entry.unique_id == unique_id
+@pytest.fixture(autouse=True)
+async def platforms() -> AsyncGenerator[list[str], None]:
+ """Return the platforms to be loaded for this test."""
+ with patch("homeassistant.components.aosmith.PLATFORMS", [Platform.SENSOR]):
+ yield
-@pytest.mark.parametrize(
- ("entity_id"),
- [
- "sensor.my_water_heater_hot_water_availability",
- "sensor.my_water_heater_energy_usage",
- ],
-)
async def test_state(
hass: HomeAssistant,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
- entity_id: str,
+ entity_registry: er.EntityRegistry,
) -> None:
"""Test the state of the sensor entities."""
- state = hass.states.get(entity_id)
- assert state == snapshot
+ await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id)
diff --git a/tests/components/aosmith/test_water_heater.py b/tests/components/aosmith/test_water_heater.py
index a256f720c0a..567121ac0b0 100644
--- a/tests/components/aosmith/test_water_heater.py
+++ b/tests/components/aosmith/test_water_heater.py
@@ -1,6 +1,7 @@
"""Tests for the water heater platform of the A. O. Smith integration."""
-from unittest.mock import MagicMock
+from collections.abc import AsyncGenerator
+from unittest.mock import MagicMock, patch
from py_aosmith.models import OperationMode
import pytest
@@ -19,53 +20,33 @@ from homeassistant.components.water_heater import (
STATE_HEAT_PUMP,
WaterHeaterEntityFeature,
)
-from homeassistant.const import (
- ATTR_ENTITY_ID,
- ATTR_FRIENDLY_NAME,
- ATTR_SUPPORTED_FEATURES,
-)
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
-from tests.common import MockConfigEntry
+from tests.common import MockConfigEntry, snapshot_platform
-async def test_setup(
- hass: HomeAssistant,
- entity_registry: er.EntityRegistry,
- init_integration: MockConfigEntry,
-) -> None:
- """Test the setup of the water heater entity."""
- entry = entity_registry.async_get("water_heater.my_water_heater")
- assert entry
- assert entry.unique_id == "junctionId"
-
- state = hass.states.get("water_heater.my_water_heater")
- assert state
- assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My water heater"
-
-
-async def test_state(
- hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion
-) -> None:
- """Test the state of the water heater entity."""
- state = hass.states.get("water_heater.my_water_heater")
- assert state == snapshot
+@pytest.fixture(autouse=True)
+async def platforms() -> AsyncGenerator[list[str], None]:
+ """Return the platforms to be loaded for this test."""
+ with patch("homeassistant.components.aosmith.PLATFORMS", [Platform.WATER_HEATER]):
+ yield
@pytest.mark.parametrize(
("get_devices_fixture_heat_pump"),
- [
- False,
- ],
+ [False, True],
)
-async def test_state_non_heat_pump(
- hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion
+async def test_state(
+ hass: HomeAssistant,
+ init_integration: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
) -> None:
- """Test the state of the water heater entity for a non heat pump device."""
- state = hass.states.get("water_heater.my_water_heater")
- assert state == snapshot
+ """Test the state of the water heater entities."""
+ await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id)
@pytest.mark.parametrize(
diff --git a/tests/components/aranet/__init__.py b/tests/components/aranet/__init__.py
index 4dc9434bd65..a6b32d56e4c 100644
--- a/tests/components/aranet/__init__.py
+++ b/tests/components/aranet/__init__.py
@@ -73,3 +73,11 @@ VALID_ARANET2_DATA_SERVICE_INFO = fake_service_info(
1794: b"\x01!\x04\x04\x01\x00\x00\x00\x00\x00\xf0\x01\x00\x00\x0c\x02\x00O\x00<\x00\x01\x00\x80"
},
)
+
+VALID_ARANET_RADIATION_DATA_SERVICE_INFO = fake_service_info(
+ "Aranet\u2622 12345",
+ "0000fce0-0000-1000-8000-00805f9b34fb",
+ {
+ 1794: b"\x02!&\x04\x01\x00`-\x00\x00\x08\x98\x05\x00n\x00\x00d\x00,\x01\xfd\x00\xc7"
+ },
+)
diff --git a/tests/components/aranet/test_sensor.py b/tests/components/aranet/test_sensor.py
index 20aea65989d..0d57f00fdf4 100644
--- a/tests/components/aranet/test_sensor.py
+++ b/tests/components/aranet/test_sensor.py
@@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant
from . import (
DISABLED_INTEGRATIONS_SERVICE_INFO,
VALID_ARANET2_DATA_SERVICE_INFO,
+ VALID_ARANET_RADIATION_DATA_SERVICE_INFO,
VALID_DATA_SERVICE_INFO,
)
@@ -15,6 +16,65 @@ from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
+async def test_sensors_aranet_radiation(
+ hass: HomeAssistant, entity_registry_enabled_by_default: None
+) -> None:
+ """Test setting up creates the sensors for Aranet Radiation device."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="aa:bb:cc:dd:ee:ff",
+ )
+ entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all("sensor")) == 0
+ inject_bluetooth_service_info(hass, VALID_ARANET_RADIATION_DATA_SERVICE_INFO)
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all("sensor")) == 4
+
+ batt_sensor = hass.states.get("sensor.aranet_12345_battery")
+ batt_sensor_attrs = batt_sensor.attributes
+ assert batt_sensor.state == "100"
+ assert batt_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet\u2622 12345 Battery"
+ assert batt_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%"
+ assert batt_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
+
+ humid_sensor = hass.states.get("sensor.aranet_12345_radiation_total_dose")
+ humid_sensor_attrs = humid_sensor.attributes
+ assert humid_sensor.state == "0.011616"
+ assert (
+ humid_sensor_attrs[ATTR_FRIENDLY_NAME]
+ == "Aranet\u2622 12345 Radiation Total Dose"
+ )
+ assert humid_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "mSv"
+ assert humid_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
+
+ temp_sensor = hass.states.get("sensor.aranet_12345_radiation_dose_rate")
+ temp_sensor_attrs = temp_sensor.attributes
+ assert temp_sensor.state == "0.11"
+ assert (
+ temp_sensor_attrs[ATTR_FRIENDLY_NAME]
+ == "Aranet\u2622 12345 Radiation Dose Rate"
+ )
+ assert temp_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "μSv/h"
+ assert temp_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
+
+ interval_sensor = hass.states.get("sensor.aranet_12345_update_interval")
+ interval_sensor_attrs = interval_sensor.attributes
+ assert interval_sensor.state == "300"
+ assert (
+ interval_sensor_attrs[ATTR_FRIENDLY_NAME]
+ == "Aranet\u2622 12345 Update Interval"
+ )
+ assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s"
+ assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
+
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+
+
async def test_sensors_aranet2(
hass: HomeAssistant, entity_registry_enabled_by_default: None
) -> None:
diff --git a/tests/components/awair/conftest.py b/tests/components/awair/conftest.py
index ec15561cc05..91c3d31e35b 100644
--- a/tests/components/awair/conftest.py
+++ b/tests/components/awair/conftest.py
@@ -7,67 +7,67 @@ import pytest
from tests.common import load_fixture
-@pytest.fixture(name="cloud_devices", scope="session")
+@pytest.fixture(name="cloud_devices", scope="package")
def cloud_devices_fixture():
"""Fixture representing devices returned by Awair Cloud API."""
return json.loads(load_fixture("awair/cloud_devices.json"))
-@pytest.fixture(name="local_devices", scope="session")
+@pytest.fixture(name="local_devices", scope="package")
def local_devices_fixture():
"""Fixture representing devices returned by Awair local API."""
return json.loads(load_fixture("awair/local_devices.json"))
-@pytest.fixture(name="gen1_data", scope="session")
+@pytest.fixture(name="gen1_data", scope="package")
def gen1_data_fixture():
"""Fixture representing data returned from Gen1 Awair device."""
return json.loads(load_fixture("awair/awair.json"))
-@pytest.fixture(name="gen2_data", scope="session")
+@pytest.fixture(name="gen2_data", scope="package")
def gen2_data_fixture():
"""Fixture representing data returned from Gen2 Awair device."""
return json.loads(load_fixture("awair/awair-r2.json"))
-@pytest.fixture(name="glow_data", scope="session")
+@pytest.fixture(name="glow_data", scope="package")
def glow_data_fixture():
"""Fixture representing data returned from Awair glow device."""
return json.loads(load_fixture("awair/glow.json"))
-@pytest.fixture(name="mint_data", scope="session")
+@pytest.fixture(name="mint_data", scope="package")
def mint_data_fixture():
"""Fixture representing data returned from Awair mint device."""
return json.loads(load_fixture("awair/mint.json"))
-@pytest.fixture(name="no_devices", scope="session")
+@pytest.fixture(name="no_devices", scope="package")
def no_devicess_fixture():
"""Fixture representing when no devices are found in Awair's cloud API."""
return json.loads(load_fixture("awair/no_devices.json"))
-@pytest.fixture(name="awair_offline", scope="session")
+@pytest.fixture(name="awair_offline", scope="package")
def awair_offline_fixture():
"""Fixture representing when Awair devices are offline."""
return json.loads(load_fixture("awair/awair-offline.json"))
-@pytest.fixture(name="omni_data", scope="session")
+@pytest.fixture(name="omni_data", scope="package")
def omni_data_fixture():
"""Fixture representing data returned from Awair omni device."""
return json.loads(load_fixture("awair/omni.json"))
-@pytest.fixture(name="user", scope="session")
+@pytest.fixture(name="user", scope="package")
def user_fixture():
"""Fixture representing the User object returned from Awair's Cloud API."""
return json.loads(load_fixture("awair/user.json"))
-@pytest.fixture(name="local_data", scope="session")
+@pytest.fixture(name="local_data", scope="package")
def local_data_fixture():
"""Fixture representing data returned from Awair local device."""
return json.loads(load_fixture("awair/awair-local.json"))
diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py
index b50a28df49f..7a4e446a0cc 100644
--- a/tests/components/axis/conftest.py
+++ b/tests/components/axis/conftest.py
@@ -114,6 +114,7 @@ def default_request_fixture(
port_management_payload: dict[str, Any],
param_properties_payload: dict[str, Any],
param_ports_payload: dict[str, Any],
+ mqtt_status_code: int,
) -> Callable[[str], None]:
"""Mock default Vapix requests responses."""
@@ -131,7 +132,7 @@ def default_request_fixture(
json=port_management_payload,
)
respx.post("/axis-cgi/mqtt/client.cgi").respond(
- json=MQTT_CLIENT_RESPONSE,
+ json=MQTT_CLIENT_RESPONSE, status_code=mqtt_status_code
)
respx.post("/axis-cgi/streamprofile.cgi").respond(
json=STREAM_PROFILES_RESPONSE,
@@ -239,6 +240,12 @@ def param_ports_data_fixture() -> dict[str, Any]:
return PORTS_RESPONSE
+@pytest.fixture(name="mqtt_status_code")
+def mqtt_status_code_fixture():
+ """Property parameter data."""
+ return 200
+
+
@pytest.fixture(name="setup_default_vapix_requests")
def default_vapix_requests_fixture(mock_vapix_requests: Callable[[str], None]) -> None:
"""Mock default Vapix requests responses."""
diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py
index 1ae6db05427..5948874f0bf 100644
--- a/tests/components/axis/test_hub.py
+++ b/tests/components/axis/test_hub.py
@@ -2,7 +2,7 @@
from ipaddress import ip_address
from unittest import mock
-from unittest.mock import Mock, patch
+from unittest.mock import Mock, call, patch
import axis as axislib
import pytest
@@ -91,7 +91,8 @@ async def test_device_support_mqtt(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_config_entry
) -> None:
"""Successful setup."""
- mqtt_mock.async_subscribe.assert_called_with(f"axis/{MAC}/#", mock.ANY, 0, "utf-8")
+ mqtt_call = call(f"axis/{MAC}/#", mock.ANY, 0, "utf-8")
+ assert mqtt_call in mqtt_mock.async_subscribe.call_args_list
topic = f"axis/{MAC}/event/tns:onvif/Device/tns:axis/Sensor/PIR/$source/sensor/0"
message = (
@@ -109,6 +110,16 @@ async def test_device_support_mqtt(
assert pir.name == f"{NAME} PIR 0"
+@pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_MQTT])
+@pytest.mark.parametrize("mqtt_status_code", [401])
+async def test_device_support_mqtt_low_privilege(
+ hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_config_entry
+) -> None:
+ """Successful setup."""
+ mqtt_call = call(f"{MAC}/#", mock.ANY, 0, "utf-8")
+ assert mqtt_call not in mqtt_mock.async_subscribe.call_args_list
+
+
async def test_update_address(
hass: HomeAssistant, setup_config_entry, mock_vapix_requests
) -> None:
diff --git a/tests/components/blueprint/test_importer.py b/tests/components/blueprint/test_importer.py
index 76f3ff36d05..275ee08863e 100644
--- a/tests/components/blueprint/test_importer.py
+++ b/tests/components/blueprint/test_importer.py
@@ -13,7 +13,7 @@ from tests.common import load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="module")
def community_post():
"""Topic JSON with a codeblock marked as auto syntax."""
return load_fixture("blueprint/community_post.json")
diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py
index d044be76e6d..33474280ec4 100644
--- a/tests/components/bluetooth/test_config_flow.py
+++ b/tests/components/bluetooth/test_config_flow.py
@@ -99,9 +99,7 @@ async def test_async_step_user_linux_one_adapter(
result["flow_id"], user_input={}
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
- assert (
- result2["title"] == "ACME Bluetooth Adapter 5.0 (cc01:aa01) (00:00:00:00:00:01)"
- )
+ assert result2["title"] == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)"
assert result2["data"] == {}
assert len(mock_setup_entry.mock_calls) == 1
@@ -144,9 +142,7 @@ async def test_async_step_user_linux_two_adapters(
result["flow_id"], user_input={CONF_ADAPTER: "hci1"}
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
- assert (
- result2["title"] == "ACME Bluetooth Adapter 5.0 (cc01:aa01) (00:00:00:00:00:02)"
- )
+ assert result2["title"] == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:02)"
assert result2["data"] == {}
assert len(mock_setup_entry.mock_calls) == 1
diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py
index 82fa0341966..8c26745d541 100644
--- a/tests/components/bluetooth/test_init.py
+++ b/tests/components/bluetooth/test_init.py
@@ -3173,3 +3173,16 @@ async def test_haos_9_or_later(
registry = async_get_issue_registry(hass)
issue = registry.async_get_issue(DOMAIN, "haos_outdated")
assert issue is None
+
+
+async def test_title_updated_if_mac_address(
+ hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, one_adapter: None
+) -> None:
+ """Test the title is updated if it is the mac address."""
+ entry = MockConfigEntry(
+ domain="bluetooth", title="00:00:00:00:00:01", unique_id="00:00:00:00:00:01"
+ )
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+ assert entry.title == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)"
diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr
new file mode 100644
index 00000000000..a27c5addd61
--- /dev/null
+++ b/tests/components/brother/snapshots/test_sensor.ambr
@@ -0,0 +1,1394 @@
+# serializer version: 1
+# name: test_sensors[sensor.hl_l2340dw_b_w_pages-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_b_w_pages',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'B/W pages',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'bw_pages',
+ 'unique_id': '0123456789_bw_counter',
+ 'unit_of_measurement': 'p',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_b_w_pages-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW B/W pages',
+ 'state_class': ,
+ 'unit_of_measurement': 'p',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_b_w_pages',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '709',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_belt_unit_remaining_lifetime-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_belt_unit_remaining_lifetime',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Belt unit remaining lifetime',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'belt_unit_remaining_life',
+ 'unique_id': '0123456789_belt_unit_remaining_life',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_belt_unit_remaining_lifetime-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Belt unit remaining lifetime',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_belt_unit_remaining_lifetime',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '97',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_black_drum_page_counter-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_black_drum_page_counter',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Black drum page counter',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'black_drum_page_counter',
+ 'unique_id': '0123456789_black_drum_counter',
+ 'unit_of_measurement': 'p',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_black_drum_page_counter-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Black drum page counter',
+ 'state_class': ,
+ 'unit_of_measurement': 'p',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_black_drum_page_counter',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '1611',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_black_drum_remaining_lifetime-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_lifetime',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Black drum remaining lifetime',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'black_drum_remaining_life',
+ 'unique_id': '0123456789_black_drum_remaining_life',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_black_drum_remaining_lifetime-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Black drum remaining lifetime',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_lifetime',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '92',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_black_drum_remaining_pages-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_pages',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Black drum remaining pages',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'black_drum_remaining_pages',
+ 'unique_id': '0123456789_black_drum_remaining_pages',
+ 'unit_of_measurement': 'p',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_black_drum_remaining_pages-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Black drum remaining pages',
+ 'state_class': ,
+ 'unit_of_measurement': 'p',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_pages',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '16389',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_black_toner_remaining-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_black_toner_remaining',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Black toner remaining',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'black_toner_remaining',
+ 'unique_id': '0123456789_black_toner_remaining',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_black_toner_remaining-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Black toner remaining',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_black_toner_remaining',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '75',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_color_pages-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_color_pages',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Color pages',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'color_pages',
+ 'unique_id': '0123456789_color_counter',
+ 'unit_of_measurement': 'p',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_color_pages-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Color pages',
+ 'state_class': ,
+ 'unit_of_measurement': 'p',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_color_pages',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '902',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_cyan_drum_page_counter-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_cyan_drum_page_counter',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Cyan drum page counter',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'cyan_drum_page_counter',
+ 'unique_id': '0123456789_cyan_drum_counter',
+ 'unit_of_measurement': 'p',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_cyan_drum_page_counter-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Cyan drum page counter',
+ 'state_class': ,
+ 'unit_of_measurement': 'p',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_cyan_drum_page_counter',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '1611',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_cyan_drum_remaining_lifetime-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_lifetime',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Cyan drum remaining lifetime',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'cyan_drum_remaining_life',
+ 'unique_id': '0123456789_cyan_drum_remaining_life',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_cyan_drum_remaining_lifetime-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Cyan drum remaining lifetime',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_lifetime',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '92',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_cyan_drum_remaining_pages-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_pages',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Cyan drum remaining pages',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'cyan_drum_remaining_pages',
+ 'unique_id': '0123456789_cyan_drum_remaining_pages',
+ 'unit_of_measurement': 'p',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_cyan_drum_remaining_pages-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Cyan drum remaining pages',
+ 'state_class': ,
+ 'unit_of_measurement': 'p',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_pages',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '16389',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_cyan_toner_remaining-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_cyan_toner_remaining',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Cyan toner remaining',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'cyan_toner_remaining',
+ 'unique_id': '0123456789_cyan_toner_remaining',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_cyan_toner_remaining-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Cyan toner remaining',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_cyan_toner_remaining',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '10',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_drum_page_counter-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_drum_page_counter',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Drum page counter',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'drum_page_counter',
+ 'unique_id': '0123456789_drum_counter',
+ 'unit_of_measurement': 'p',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_drum_page_counter-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Drum page counter',
+ 'state_class': ,
+ 'unit_of_measurement': 'p',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_drum_page_counter',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '986',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_drum_remaining_lifetime-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_drum_remaining_lifetime',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Drum remaining lifetime',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'drum_remaining_life',
+ 'unique_id': '0123456789_drum_remaining_life',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_drum_remaining_lifetime-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Drum remaining lifetime',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_drum_remaining_lifetime',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '92',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_drum_remaining_pages-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_drum_remaining_pages',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Drum remaining pages',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'drum_remaining_pages',
+ 'unique_id': '0123456789_drum_remaining_pages',
+ 'unit_of_measurement': 'p',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_drum_remaining_pages-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Drum remaining pages',
+ 'state_class': ,
+ 'unit_of_measurement': 'p',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_drum_remaining_pages',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '11014',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_duplex_unit_page_counter-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.hl_l2340dw_duplex_unit_page_counter',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Duplex unit page counter',
+ 'platform': 'brother',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'duplex_unit_page_counter',
+ 'unique_id': '0123456789_duplex_unit_pages_counter',
+ 'unit_of_measurement': 'p',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_duplex_unit_page_counter-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'HL-L2340DW Duplex unit page counter',
+ 'state_class': ,
+ 'unit_of_measurement': 'p',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.hl_l2340dw_duplex_unit_page_counter',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '538',
+ })
+# ---
+# name: test_sensors[sensor.hl_l2340dw_fuser_remaining_lifetime-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class':