Merge branch 'dev' into block_pyserial_asyncio

This commit is contained in:
J. Nick Koston 2024-08-16 17:48:47 -05:00 committed by GitHub
commit e7e42dc318
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4709 changed files with 203947 additions and 124747 deletions

View File

@ -49,6 +49,7 @@ base_platforms: &base_platforms
- homeassistant/components/tts/** - homeassistant/components/tts/**
- homeassistant/components/update/** - homeassistant/components/update/**
- homeassistant/components/vacuum/** - homeassistant/components/vacuum/**
- homeassistant/components/valve/**
- homeassistant/components/water_heater/** - homeassistant/components/water_heater/**
- homeassistant/components/weather/** - homeassistant/components/weather/**
@ -145,6 +146,7 @@ requirements: &requirements
- homeassistant/package_constraints.txt - homeassistant/package_constraints.txt
- requirements*.txt - requirements*.txt
- pyproject.toml - pyproject.toml
- script/licenses.py
any: any:
- *base_platforms - *base_platforms

File diff suppressed because it is too large Load Diff

View File

@ -74,7 +74,6 @@ If the code communicates with devices, web services, or third-party tools:
- [ ] New or updated dependencies have been added to `requirements_all.txt`. - [ ] New or updated dependencies have been added to `requirements_all.txt`.
Updated by running `python3 -m script.gen_requirements_all`. Updated by running `python3 -m script.gen_requirements_all`.
- [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description. - [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.
- [ ] Untested files have been added to `.coveragerc`.
<!-- <!--
This project is very active and we have a high turnover of pull requests. This project is very active and we have a high turnover of pull requests.

View File

@ -32,7 +32,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations - name: Upload translations
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: translations name: translations
path: translations.tar.gz path: translations.tar.gz
@ -116,7 +116,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations - name: Download translations
uses: actions/download-artifact@v4.1.7 uses: actions/download-artifact@v4.1.8
with: with:
name: translations name: translations
@ -190,14 +190,14 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.2.0 uses: docker/login-action@v3.3.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image - name: Build base image
uses: home-assistant/builder@2024.03.5 uses: home-assistant/builder@2024.08.1
with: with:
args: | args: |
$BUILD_ARGS \ $BUILD_ARGS \
@ -256,14 +256,14 @@ jobs:
fi fi
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.2.0 uses: docker/login-action@v3.3.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image - name: Build base image
uses: home-assistant/builder@2024.03.5 uses: home-assistant/builder@2024.08.1
with: with:
args: | args: |
$BUILD_ARGS \ $BUILD_ARGS \
@ -323,20 +323,20 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@v3.5.0 uses: sigstore/cosign-installer@v3.6.0
with: with:
cosign-release: "v2.2.3" cosign-release: "v2.2.3"
- name: Login to DockerHub - name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant' if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@v3.2.0 uses: docker/login-action@v3.3.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant' if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@v3.2.0 uses: docker/login-action@v3.3.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@ -453,12 +453,12 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations - name: Download translations
uses: actions/download-artifact@v4.1.7 uses: actions/download-artifact@v4.1.8
with: with:
name: translations name: translations

View File

@ -31,12 +31,16 @@ on:
description: "Only run mypy" description: "Only run mypy"
default: false default: false
type: boolean type: boolean
audit-licenses-only:
description: "Only run audit licenses"
default: false
type: boolean
env: env:
CACHE_VERSION: 9 CACHE_VERSION: 10
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 8 MYPY_CACHE_VERSION: 8
HA_SHORT_VERSION: "2024.7" HA_SHORT_VERSION: "2024.9"
DEFAULT_PYTHON: "3.12" DEFAULT_PYTHON: "3.12"
ALL_PYTHON_VERSIONS: "['3.12']" ALL_PYTHON_VERSIONS: "['3.12']"
# 10.3 is the oldest supported version # 10.3 is the oldest supported version
@ -86,7 +90,7 @@ jobs:
tests_glob: ${{ steps.info.outputs.tests_glob }} tests_glob: ${{ steps.info.outputs.tests_glob }}
tests: ${{ steps.info.outputs.tests }} tests: ${{ steps.info.outputs.tests }}
skip_coverage: ${{ steps.info.outputs.skip_coverage }} skip_coverage: ${{ steps.info.outputs.skip_coverage }}
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
@ -218,10 +222,11 @@ jobs:
pre-commit: pre-commit:
name: Prepare pre-commit base name: Prepare pre-commit base
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
if: | if: |
github.event.inputs.pylint-only != 'true' github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true' && github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
needs: needs:
- info - info
steps: steps:
@ -229,7 +234,7 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -266,7 +271,7 @@ jobs:
lint-ruff-format: lint-ruff-format:
name: Check ruff-format name: Check ruff-format
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
needs: needs:
- info - info
- pre-commit - pre-commit
@ -274,7 +279,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -306,7 +311,7 @@ jobs:
lint-ruff: lint-ruff:
name: Check ruff name: Check ruff
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
needs: needs:
- info - info
- pre-commit - pre-commit
@ -314,7 +319,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -343,9 +348,10 @@ jobs:
pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure
env: env:
RUFF_OUTPUT_FORMAT: github RUFF_OUTPUT_FORMAT: github
lint-other: lint-other:
name: Check other linters name: Check other linters
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
needs: needs:
- info - info
- pre-commit - pre-commit
@ -353,7 +359,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -437,7 +443,7 @@ jobs:
base: base:
name: Prepare dependencies name: Prepare dependencies
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
needs: info needs: info
timeout-minutes: 60 timeout-minutes: 60
strategy: strategy:
@ -448,7 +454,7 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -488,6 +494,7 @@ jobs:
sudo apt-get -y install \ sudo apt-get -y install \
bluez \ bluez \
ffmpeg \ ffmpeg \
libturbojpeg \
libavcodec-dev \ libavcodec-dev \
libavdevice-dev \ libavdevice-dev \
libavfilter-dev \ libavfilter-dev \
@ -507,25 +514,31 @@ jobs:
uv pip install -U "pip>=21.3.1" setuptools wheel uv pip install -U "pip>=21.3.1" setuptools wheel
uv pip install -r requirements.txt uv pip install -r requirements.txt
python -m script.gen_requirements_all ci python -m script.gen_requirements_all ci
uv pip install -r requirements_all_pytest.txt uv pip install -r requirements_all_pytest.txt -r requirements_test.txt
uv pip install -r requirements_test.txt
uv pip install -e . --config-settings editable_mode=compat uv pip install -e . --config-settings editable_mode=compat
hassfest: hassfest:
name: Check hassfest name: Check hassfest
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
if: | if: |
github.event.inputs.pylint-only != 'true' github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true' && github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
needs: needs:
- info - info
- base - base
steps: 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 \
libturbojpeg
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -545,10 +558,11 @@ jobs:
gen-requirements-all: gen-requirements-all:
name: Check all requirements name: Check all requirements
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
if: | if: |
github.event.inputs.pylint-only != 'true' github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true' && github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
needs: needs:
- info - info
- base - base
@ -557,7 +571,7 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -575,12 +589,56 @@ jobs:
. venv/bin/activate . venv/bin/activate
python -m script.gen_requirements_all validate python -m script.gen_requirements_all validate
audit-licenses:
name: Audit licenses
runs-on: ubuntu-24.04
needs:
- info
- base
if: |
(github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
|| github.event.inputs.audit-licenses-only == 'true')
&& needs.info.outputs.requirements == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.1.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.0.2
with:
path: venv
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Run pip-licenses
run: |
. venv/bin/activate
pip-licenses --format=json --output-file=licenses.json
- name: Upload licenses
uses: actions/upload-artifact@v4.3.6
with:
name: licenses
path: licenses.json
- name: Process licenses
run: |
. venv/bin/activate
python -m script.licenses
pylint: pylint:
name: Check pylint name: Check pylint
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
timeout-minutes: 20 timeout-minutes: 20
if: | if: |
github.event.inputs.mypy-only != 'true' github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
|| github.event.inputs.pylint-only == 'true' || github.event.inputs.pylint-only == 'true'
needs: needs:
- info - info
@ -590,7 +648,7 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -622,10 +680,12 @@ jobs:
pylint-tests: pylint-tests:
name: Check pylint on tests name: Check pylint on tests
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
timeout-minutes: 20 timeout-minutes: 20
if: | if: |
(github.event.inputs.mypy-only != 'true' || github.event.inputs.pylint-only == 'true') (github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
|| github.event.inputs.pylint-only == 'true')
&& (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true') && (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true')
needs: needs:
- info - info
@ -635,7 +695,7 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -667,9 +727,10 @@ jobs:
mypy: mypy:
name: Check mypy name: Check mypy
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
if: | if: |
github.event.inputs.pylint-only != 'true' github.event.inputs.pylint-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
|| github.event.inputs.mypy-only == 'true' || github.event.inputs.mypy-only == 'true'
needs: needs:
- info - info
@ -679,7 +740,7 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -728,12 +789,13 @@ jobs:
mypy homeassistant/components/${{ needs.info.outputs.integrations_glob }} mypy homeassistant/components/${{ needs.info.outputs.integrations_glob }}
prepare-pytest-full: prepare-pytest-full:
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
if: | if: |
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true' && github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true' && github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true' && github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.test_full_suite == 'true' && needs.info.outputs.test_full_suite == 'true'
needs: needs:
- info - info
@ -747,12 +809,13 @@ jobs:
sudo apt-get -y install \ sudo apt-get -y install \
bluez \ bluez \
ffmpeg \ ffmpeg \
libturbojpeg \
libgammu-dev libgammu-dev
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -770,19 +833,20 @@ jobs:
. venv/bin/activate . venv/bin/activate
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
- name: Upload pytest_buckets - name: Upload pytest_buckets
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: pytest_buckets name: pytest_buckets
path: pytest_buckets.txt path: pytest_buckets.txt
overwrite: true overwrite: true
pytest-full: pytest-full:
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
if: | if: |
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true' && github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true' && github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true' && github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.test_full_suite == 'true' && needs.info.outputs.test_full_suite == 'true'
needs: needs:
- info - info
@ -809,12 +873,13 @@ jobs:
sudo apt-get -y install \ sudo apt-get -y install \
bluez \ bluez \
ffmpeg \ ffmpeg \
libturbojpeg \
libgammu-dev libgammu-dev
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -833,7 +898,7 @@ jobs:
run: | run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets - name: Download pytest_buckets
uses: actions/download-artifact@v4.1.7 uses: actions/download-artifact@v4.1.8
with: with:
name: pytest_buckets name: pytest_buckets
- name: Compile English translations - name: Compile English translations
@ -855,6 +920,7 @@ jobs:
cov_params+=(--cov-report=xml) cov_params+=(--cov-report=xml)
fi fi
echo "Test group ${{ matrix.group }}: $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt)"
python3 -b -X dev -m pytest \ python3 -b -X dev -m pytest \
-qq \ -qq \
--timeout=9 \ --timeout=9 \
@ -868,14 +934,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure' if: success() || failure() && steps.pytest-full.conclusion == 'failure'
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt path: pytest-*.txt
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }} name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml path: coverage.xml
@ -887,7 +953,7 @@ jobs:
./script/check_dirty ./script/check_dirty
pytest-mariadb: pytest-mariadb:
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
services: services:
mariadb: mariadb:
image: ${{ matrix.mariadb-group }} image: ${{ matrix.mariadb-group }}
@ -901,6 +967,7 @@ jobs:
&& github.event.inputs.lint-only != 'true' && github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true' && github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true' && github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.mariadb_groups != '[]' && needs.info.outputs.mariadb_groups != '[]'
needs: needs:
- info - info
@ -926,12 +993,13 @@ jobs:
sudo apt-get -y install \ sudo apt-get -y install \
bluez \ bluez \
ffmpeg \ ffmpeg \
libturbojpeg \
libmariadb-dev-compat libmariadb-dev-compat
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -992,7 +1060,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure' if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }} steps.pytest-partial.outputs.mariadb }}
@ -1000,7 +1068,7 @@ jobs:
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: coverage-${{ matrix.python-version }}-${{ name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }} steps.pytest-partial.outputs.mariadb }}
@ -1025,6 +1093,7 @@ jobs:
&& github.event.inputs.lint-only != 'true' && github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true' && github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true' && github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.postgresql_groups != '[]' && needs.info.outputs.postgresql_groups != '[]'
needs: needs:
- info - info
@ -1050,12 +1119,13 @@ jobs:
sudo apt-get -y install \ sudo apt-get -y install \
bluez \ bluez \
ffmpeg \ ffmpeg \
libturbojpeg \
postgresql-server-dev-14 postgresql-server-dev-14
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -1117,7 +1187,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure' if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }} steps.pytest-partial.outputs.postgresql }}
@ -1125,7 +1195,7 @@ jobs:
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: coverage-${{ matrix.python-version }}-${{ name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }} steps.pytest-partial.outputs.postgresql }}
@ -1138,7 +1208,7 @@ jobs:
coverage-full: coverage-full:
name: Upload test coverage to Codecov (full suite) name: Upload test coverage to Codecov (full suite)
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
needs: needs:
- info - info
- pytest-full - pytest-full
@ -1149,7 +1219,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v4.1.7 uses: actions/download-artifact@v4.1.8
with: with:
pattern: coverage-* pattern: coverage-*
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
@ -1162,12 +1232,13 @@ jobs:
version: v0.6.0 version: v0.6.0
pytest-partial: pytest-partial:
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
if: | if: |
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true' && github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true' && github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true' && github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.tests_glob && needs.info.outputs.tests_glob
&& needs.info.outputs.test_full_suite == 'false' && needs.info.outputs.test_full_suite == 'false'
needs: needs:
@ -1194,12 +1265,13 @@ jobs:
sudo apt-get -y install \ sudo apt-get -y install \
bluez \ bluez \
ffmpeg \ ffmpeg \
libturbojpeg \
libgammu-dev libgammu-dev
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -1257,14 +1329,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure' if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt path: pytest-*.txt
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }} name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml path: coverage.xml
@ -1276,7 +1348,7 @@ jobs:
coverage-partial: coverage-partial:
name: Upload test coverage to Codecov (partial suite) name: Upload test coverage to Codecov (partial suite)
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
needs: needs:
- info - info
- pytest-partial - pytest-partial
@ -1285,7 +1357,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v4.1.7 uses: actions/download-artifact@v4.1.8
with: with:
pattern: coverage-* pattern: coverage-*
- name: Upload coverage to Codecov - name: Upload coverage to Codecov

View File

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.25.10 uses: github/codeql-action/init@v3.26.2
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.25.10 uses: github/codeql-action/analyze@v3.26.2
with: with:
category: "/language:python" category: "/language:python"

View File

@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}

View File

@ -36,7 +36,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.1.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -82,14 +82,14 @@ jobs:
) > .env_file ) > .env_file
- name: Upload env_file - name: Upload env_file
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: env_file name: env_file
path: ./.env_file path: ./.env_file
overwrite: true overwrite: true
- name: Upload requirements_diff - name: Upload requirements_diff
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: requirements_diff name: requirements_diff
path: ./requirements_diff.txt path: ./requirements_diff.txt
@ -101,7 +101,7 @@ jobs:
python -m script.gen_requirements_all ci python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels - name: Upload requirements_all_wheels
uses: actions/upload-artifact@v4.3.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: requirements_all_wheels name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt path: ./requirements_all_wheels_*.txt
@ -121,17 +121,17 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@v4.1.7 uses: actions/download-artifact@v4.1.8
with: with:
name: env_file name: env_file
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@v4.1.7 uses: actions/download-artifact@v4.1.8
with: with:
name: requirements_diff name: requirements_diff
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@2024.01.0 uses: home-assistant/wheels@2024.07.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@ -159,17 +159,17 @@ jobs:
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@v4.1.7 uses: actions/download-artifact@v4.1.8
with: with:
name: env_file name: env_file
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@v4.1.7 uses: actions/download-artifact@v4.1.8
with: with:
name: requirements_diff name: requirements_diff
- name: Download requirements_all_wheels - name: Download requirements_all_wheels
uses: actions/download-artifact@v4.1.7 uses: actions/download-artifact@v4.1.8
with: with:
name: requirements_all_wheels name: requirements_all_wheels
@ -203,7 +203,7 @@ jobs:
sed -i "/numpy/d" homeassistant/package_constraints.txt sed -i "/numpy/d" homeassistant/package_constraints.txt
- name: Build wheels (old cython) - name: Build wheels (old cython)
uses: home-assistant/wheels@2024.01.0 uses: home-assistant/wheels@2024.07.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@ -211,14 +211,14 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_old-cython.txt" requirements: "requirements_old-cython.txt"
pip: "'cython<3'" pip: "'cython<3'"
- name: Build wheels (part 1) - name: Build wheels (part 1)
uses: home-assistant/wheels@2024.01.0 uses: home-assistant/wheels@2024.07.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@ -226,13 +226,13 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtaa" requirements: "requirements_all.txtaa"
- name: Build wheels (part 2) - name: Build wheels (part 2)
uses: home-assistant/wheels@2024.01.0 uses: home-assistant/wheels@2024.07.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@ -240,13 +240,13 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtab" requirements: "requirements_all.txtab"
- name: Build wheels (part 3) - name: Build wheels (part 3)
uses: home-assistant/wheels@2024.01.0 uses: home-assistant/wheels@2024.07.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@ -254,7 +254,7 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtac" requirements: "requirements_all.txtac"

View File

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.9 rev: v0.6.0
hooks: hooks:
- id: ruff - id: ruff
args: args:
@ -12,7 +12,7 @@ repos:
hooks: hooks:
- id: codespell - id: codespell
args: args:
- --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn,pres,ser,ue - --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn
- --skip="./.*,*.csv,*.json,*.ambr" - --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2 - --quiet-level=2
exclude_types: [csv, json, html] exclude_types: [csv, json, html]
@ -83,7 +83,7 @@ repos:
pass_filenames: false pass_filenames: false
language: script language: script
types: [text] types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|\.coveragerc|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements_test.txt)$ files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements_test.txt)$
- id: hassfest-metadata - id: hassfest-metadata
name: hassfest-metadata name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata entry: script/run-in-env.sh python3 -m script.hassfest -p metadata

View File

@ -21,6 +21,7 @@ homeassistant.helpers.entity_platform
homeassistant.helpers.entity_values homeassistant.helpers.entity_values
homeassistant.helpers.event homeassistant.helpers.event
homeassistant.helpers.reload homeassistant.helpers.reload
homeassistant.helpers.script
homeassistant.helpers.script_variables homeassistant.helpers.script_variables
homeassistant.helpers.singleton homeassistant.helpers.singleton
homeassistant.helpers.sun homeassistant.helpers.sun
@ -94,9 +95,8 @@ homeassistant.components.aruba.*
homeassistant.components.arwn.* homeassistant.components.arwn.*
homeassistant.components.aseko_pool_live.* homeassistant.components.aseko_pool_live.*
homeassistant.components.assist_pipeline.* homeassistant.components.assist_pipeline.*
homeassistant.components.asterisk_cdr.*
homeassistant.components.asterisk_mbox.*
homeassistant.components.asuswrt.* homeassistant.components.asuswrt.*
homeassistant.components.autarco.*
homeassistant.components.auth.* homeassistant.components.auth.*
homeassistant.components.automation.* homeassistant.components.automation.*
homeassistant.components.awair.* homeassistant.components.awair.*
@ -118,6 +118,7 @@ homeassistant.components.bond.*
homeassistant.components.braviatv.* homeassistant.components.braviatv.*
homeassistant.components.brother.* homeassistant.components.brother.*
homeassistant.components.browser.* homeassistant.components.browser.*
homeassistant.components.bryant_evolution.*
homeassistant.components.bthome.* homeassistant.components.bthome.*
homeassistant.components.button.* homeassistant.components.button.*
homeassistant.components.calendar.* homeassistant.components.calendar.*
@ -165,6 +166,7 @@ homeassistant.components.ecowitt.*
homeassistant.components.efergy.* homeassistant.components.efergy.*
homeassistant.components.electrasmart.* homeassistant.components.electrasmart.*
homeassistant.components.electric_kiwi.* homeassistant.components.electric_kiwi.*
homeassistant.components.elevenlabs.*
homeassistant.components.elgato.* homeassistant.components.elgato.*
homeassistant.components.elkm1.* homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.* homeassistant.components.emulated_hue.*
@ -195,6 +197,7 @@ homeassistant.components.fritzbox_callmonitor.*
homeassistant.components.fronius.* homeassistant.components.fronius.*
homeassistant.components.frontend.* homeassistant.components.frontend.*
homeassistant.components.fully_kiosk.* homeassistant.components.fully_kiosk.*
homeassistant.components.fyta.*
homeassistant.components.generic_hygrostat.* homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.* homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.* homeassistant.components.geo_location.*
@ -253,6 +256,7 @@ homeassistant.components.integration.*
homeassistant.components.intent.* homeassistant.components.intent.*
homeassistant.components.intent_script.* homeassistant.components.intent_script.*
homeassistant.components.ios.* homeassistant.components.ios.*
homeassistant.components.iotty.*
homeassistant.components.ipp.* homeassistant.components.ipp.*
homeassistant.components.iqvia.* homeassistant.components.iqvia.*
homeassistant.components.islamic_prayer_times.* homeassistant.components.islamic_prayer_times.*
@ -277,6 +281,7 @@ homeassistant.components.lidarr.*
homeassistant.components.lifx.* homeassistant.components.lifx.*
homeassistant.components.light.* homeassistant.components.light.*
homeassistant.components.linear_garage_door.* homeassistant.components.linear_garage_door.*
homeassistant.components.linkplay.*
homeassistant.components.litejet.* homeassistant.components.litejet.*
homeassistant.components.litterrobot.* homeassistant.components.litterrobot.*
homeassistant.components.local_ip.* homeassistant.components.local_ip.*
@ -287,6 +292,7 @@ homeassistant.components.logger.*
homeassistant.components.london_underground.* homeassistant.components.london_underground.*
homeassistant.components.lookin.* homeassistant.components.lookin.*
homeassistant.components.luftdaten.* homeassistant.components.luftdaten.*
homeassistant.components.madvr.*
homeassistant.components.mailbox.* homeassistant.components.mailbox.*
homeassistant.components.map.* homeassistant.components.map.*
homeassistant.components.mastodon.* homeassistant.components.mastodon.*
@ -370,6 +376,7 @@ homeassistant.components.rhasspy.*
homeassistant.components.ridwell.* homeassistant.components.ridwell.*
homeassistant.components.ring.* homeassistant.components.ring.*
homeassistant.components.rituals_perfume_genie.* homeassistant.components.rituals_perfume_genie.*
homeassistant.components.roborock.*
homeassistant.components.roku.* homeassistant.components.roku.*
homeassistant.components.romy.* homeassistant.components.romy.*
homeassistant.components.rpi_power.* homeassistant.components.rpi_power.*
@ -381,6 +388,7 @@ homeassistant.components.samsungtv.*
homeassistant.components.scene.* homeassistant.components.scene.*
homeassistant.components.schedule.* homeassistant.components.schedule.*
homeassistant.components.scrape.* homeassistant.components.scrape.*
homeassistant.components.script.*
homeassistant.components.search.* homeassistant.components.search.*
homeassistant.components.select.* homeassistant.components.select.*
homeassistant.components.sensibo.* homeassistant.components.sensibo.*

42
.vscode/launch.json vendored
View File

@ -6,38 +6,52 @@
"configurations": [ "configurations": [
{ {
"name": "Home Assistant", "name": "Home Assistant",
"type": "python", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "homeassistant", "module": "homeassistant",
"justMyCode": false, "justMyCode": false,
"args": ["--debug", "-c", "config"], "args": [
"--debug",
"-c",
"config"
],
"preLaunchTask": "Compile English translations" "preLaunchTask": "Compile English translations"
}, },
{ {
"name": "Home Assistant (skip pip)", "name": "Home Assistant (skip pip)",
"type": "python", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "homeassistant", "module": "homeassistant",
"justMyCode": false, "justMyCode": false,
"args": ["--debug", "-c", "config", "--skip-pip"], "args": [
"--debug",
"-c",
"config",
"--skip-pip"
],
"preLaunchTask": "Compile English translations" "preLaunchTask": "Compile English translations"
}, },
{ {
"name": "Home Assistant: Changed tests", "name": "Home Assistant: Changed tests",
"type": "python", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "pytest", "module": "pytest",
"justMyCode": false, "justMyCode": false,
"args": ["--timeout=10", "--picked"], "args": [
"--timeout=10",
"--picked"
],
}, },
{ {
// Debug by attaching to local Home Assistant server using Remote Python Debugger. // Debug by attaching to local Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/ // See https://www.home-assistant.io/integrations/debugpy/
"name": "Home Assistant: Attach Local", "name": "Home Assistant: Attach Local",
"type": "python", "type": "debugpy",
"request": "attach", "request": "attach",
"port": 5678, "connect": {
"host": "localhost", "port": 5678,
"host": "localhost"
},
"pathMappings": [ "pathMappings": [
{ {
"localRoot": "${workspaceFolder}", "localRoot": "${workspaceFolder}",
@ -49,10 +63,12 @@
// Debug by attaching to remote Home Assistant server using Remote Python Debugger. // Debug by attaching to remote Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/ // See https://www.home-assistant.io/integrations/debugpy/
"name": "Home Assistant: Attach Remote", "name": "Home Assistant: Attach Remote",
"type": "python", "type": "debugpy",
"request": "attach", "request": "attach",
"port": 5678, "connect": {
"host": "homeassistant.local", "port": 5678,
"host": "homeassistant.local"
},
"pathMappings": [ "pathMappings": [
{ {
"localRoot": "${workspaceFolder}", "localRoot": "${workspaceFolder}",
@ -61,4 +77,4 @@
] ]
} }
] ]
} }

1
.vscode/tasks.json vendored
View File

@ -76,6 +76,7 @@
"detail": "Generate code coverage report for a given integration.", "detail": "Generate code coverage report for a given integration.",
"type": "shell", "type": "shell",
"command": "python3 -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", "command": "python3 -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto",
"dependsOn": ["Compile English translations"],
"group": { "group": {
"kind": "test", "kind": "test",
"isDefault": true "isDefault": true

View File

@ -80,8 +80,6 @@ build.json @home-assistant/supervisor
/tests/components/airzone/ @Noltari /tests/components/airzone/ @Noltari
/homeassistant/components/airzone_cloud/ @Noltari /homeassistant/components/airzone_cloud/ @Noltari
/tests/components/airzone_cloud/ @Noltari /tests/components/airzone_cloud/ @Noltari
/homeassistant/components/aladdin_connect/ @swcloudgenie
/tests/components/aladdin_connect/ @swcloudgenie
/homeassistant/components/alarm_control_panel/ @home-assistant/core /homeassistant/components/alarm_control_panel/ @home-assistant/core
/tests/components/alarm_control_panel/ @home-assistant/core /tests/components/alarm_control_panel/ @home-assistant/core
/homeassistant/components/alert/ @home-assistant/core @frenck /homeassistant/components/alert/ @home-assistant/core @frenck
@ -110,6 +108,8 @@ build.json @home-assistant/supervisor
/tests/components/anova/ @Lash-L /tests/components/anova/ @Lash-L
/homeassistant/components/anthemav/ @hyralex /homeassistant/components/anthemav/ @hyralex
/tests/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex
/homeassistant/components/anthropic/ @Shulyaka
/tests/components/anthropic/ @Shulyaka
/homeassistant/components/aosmith/ @bdr99 /homeassistant/components/aosmith/ @bdr99
/tests/components/aosmith/ @bdr99 /tests/components/aosmith/ @bdr99
/homeassistant/components/apache_kafka/ @bachya /homeassistant/components/apache_kafka/ @bachya
@ -157,6 +157,8 @@ build.json @home-assistant/supervisor
/tests/components/aurora_abb_powerone/ @davet2001 /tests/components/aurora_abb_powerone/ @davet2001
/homeassistant/components/aussie_broadband/ @nickw444 @Bre77 /homeassistant/components/aussie_broadband/ @nickw444 @Bre77
/tests/components/aussie_broadband/ @nickw444 @Bre77 /tests/components/aussie_broadband/ @nickw444 @Bre77
/homeassistant/components/autarco/ @klaasnicolaas
/tests/components/autarco/ @klaasnicolaas
/homeassistant/components/auth/ @home-assistant/core /homeassistant/components/auth/ @home-assistant/core
/tests/components/auth/ @home-assistant/core /tests/components/auth/ @home-assistant/core
/homeassistant/components/automation/ @home-assistant/core /homeassistant/components/automation/ @home-assistant/core
@ -197,7 +199,8 @@ build.json @home-assistant/supervisor
/tests/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco
/homeassistant/components/blueprint/ @home-assistant/core /homeassistant/components/blueprint/ @home-assistant/core
/tests/components/blueprint/ @home-assistant/core /tests/components/blueprint/ @home-assistant/core
/homeassistant/components/bluesound/ @thrawnarn /homeassistant/components/bluesound/ @thrawnarn @LouisChrist
/tests/components/bluesound/ @thrawnarn @LouisChrist
/homeassistant/components/bluetooth/ @bdraco /homeassistant/components/bluetooth/ @bdraco
/tests/components/bluetooth/ @bdraco /tests/components/bluetooth/ @bdraco
/homeassistant/components/bluetooth_adapters/ @bdraco /homeassistant/components/bluetooth_adapters/ @bdraco
@ -220,6 +223,8 @@ build.json @home-assistant/supervisor
/tests/components/brottsplatskartan/ @gjohansson-ST /tests/components/brottsplatskartan/ @gjohansson-ST
/homeassistant/components/brunt/ @eavanvalkenburg /homeassistant/components/brunt/ @eavanvalkenburg
/tests/components/brunt/ @eavanvalkenburg /tests/components/brunt/ @eavanvalkenburg
/homeassistant/components/bryant_evolution/ @danielsmyers
/tests/components/bryant_evolution/ @danielsmyers
/homeassistant/components/bsblan/ @liudger /homeassistant/components/bsblan/ @liudger
/tests/components/bsblan/ @liudger /tests/components/bsblan/ @liudger
/homeassistant/components/bt_smarthub/ @typhoon2099 /homeassistant/components/bt_smarthub/ @typhoon2099
@ -239,6 +244,8 @@ build.json @home-assistant/supervisor
/tests/components/ccm15/ @ocalvo /tests/components/ccm15/ @ocalvo
/homeassistant/components/cert_expiry/ @jjlawren /homeassistant/components/cert_expiry/ @jjlawren
/tests/components/cert_expiry/ @jjlawren /tests/components/cert_expiry/ @jjlawren
/homeassistant/components/chacon_dio/ @cnico
/tests/components/chacon_dio/ @cnico
/homeassistant/components/cisco_ios/ @fbradyirl /homeassistant/components/cisco_ios/ @fbradyirl
/homeassistant/components/cisco_mobility_express/ @fbradyirl /homeassistant/components/cisco_mobility_express/ @fbradyirl
/homeassistant/components/cisco_webex_teams/ @fbradyirl /homeassistant/components/cisco_webex_teams/ @fbradyirl
@ -342,8 +349,8 @@ build.json @home-assistant/supervisor
/tests/components/dremel_3d_printer/ @tkdrob /tests/components/dremel_3d_printer/ @tkdrob
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
/tests/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer
/homeassistant/components/dsmr/ @Robbie1221 @frenck /homeassistant/components/dsmr/ @Robbie1221
/tests/components/dsmr/ @Robbie1221 @frenck /tests/components/dsmr/ @Robbie1221
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duotecno/ @cereal2nd /homeassistant/components/duotecno/ @cereal2nd
@ -360,8 +367,8 @@ build.json @home-assistant/supervisor
/tests/components/ecoforest/ @pjanuario /tests/components/ecoforest/ @pjanuario
/homeassistant/components/econet/ @w1ll1am23 /homeassistant/components/econet/ @w1ll1am23
/tests/components/econet/ @w1ll1am23 /tests/components/econet/ @w1ll1am23
/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus @Augar /homeassistant/components/ecovacs/ @mib1185 @edenhaus @Augar
/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus @Augar /tests/components/ecovacs/ @mib1185 @edenhaus @Augar
/homeassistant/components/ecowitt/ @pvizeli /homeassistant/components/ecowitt/ @pvizeli
/tests/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli
/homeassistant/components/efergy/ @tkdrob /homeassistant/components/efergy/ @tkdrob
@ -371,6 +378,8 @@ build.json @home-assistant/supervisor
/tests/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000 /homeassistant/components/electric_kiwi/ @mikey0000
/tests/components/electric_kiwi/ @mikey0000 /tests/components/electric_kiwi/ @mikey0000
/homeassistant/components/elevenlabs/ @sorgfresser
/tests/components/elevenlabs/ @sorgfresser
/homeassistant/components/elgato/ @frenck /homeassistant/components/elgato/ @frenck
/tests/components/elgato/ @frenck /tests/components/elgato/ @frenck
/homeassistant/components/elkm1/ @gwww @bdraco /homeassistant/components/elkm1/ @gwww @bdraco
@ -382,6 +391,7 @@ build.json @home-assistant/supervisor
/tests/components/elvia/ @ludeeus /tests/components/elvia/ @ludeeus
/homeassistant/components/emby/ @mezz64 /homeassistant/components/emby/ @mezz64
/homeassistant/components/emoncms/ @borpin @alexandrecuer /homeassistant/components/emoncms/ @borpin @alexandrecuer
/tests/components/emoncms/ @borpin @alexandrecuer
/homeassistant/components/emonitor/ @bdraco /homeassistant/components/emonitor/ @bdraco
/tests/components/emonitor/ @bdraco /tests/components/emonitor/ @bdraco
/homeassistant/components/emulated_hue/ @bdraco @Tho85 /homeassistant/components/emulated_hue/ @bdraco @Tho85
@ -398,8 +408,8 @@ build.json @home-assistant/supervisor
/tests/components/enigma2/ @autinerd /tests/components/enigma2/ @autinerd
/homeassistant/components/enocean/ @bdurrer /homeassistant/components/enocean/ @bdurrer
/tests/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac /tests/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie
@ -423,6 +433,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/evil_genius_labs/ @balloob /homeassistant/components/evil_genius_labs/ @balloob
/tests/components/evil_genius_labs/ @balloob /tests/components/evil_genius_labs/ @balloob
/homeassistant/components/evohome/ @zxdavb /homeassistant/components/evohome/ @zxdavb
/tests/components/evohome/ @zxdavb
/homeassistant/components/ezviz/ @RenierM26 @baqs /homeassistant/components/ezviz/ @RenierM26 @baqs
/tests/components/ezviz/ @RenierM26 @baqs /tests/components/ezviz/ @RenierM26 @baqs
/homeassistant/components/faa_delays/ @ntilley905 /homeassistant/components/faa_delays/ @ntilley905
@ -431,6 +442,8 @@ build.json @home-assistant/supervisor
/tests/components/fan/ @home-assistant/core /tests/components/fan/ @home-assistant/core
/homeassistant/components/fastdotcom/ @rohankapoorcom @erwindouna /homeassistant/components/fastdotcom/ @rohankapoorcom @erwindouna
/tests/components/fastdotcom/ @rohankapoorcom @erwindouna /tests/components/fastdotcom/ @rohankapoorcom @erwindouna
/homeassistant/components/feedreader/ @mib1185
/tests/components/feedreader/ @mib1185
/homeassistant/components/fibaro/ @rappenze /homeassistant/components/fibaro/ @rappenze
/tests/components/fibaro/ @rappenze /tests/components/fibaro/ @rappenze
/homeassistant/components/file/ @fabaff /homeassistant/components/file/ @fabaff
@ -501,6 +514,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/generic_hygrostat/ @Shulyaka /homeassistant/components/generic_hygrostat/ @Shulyaka
/tests/components/generic_hygrostat/ @Shulyaka /tests/components/generic_hygrostat/ @Shulyaka
/homeassistant/components/geniushub/ @manzanotti /homeassistant/components/geniushub/ @manzanotti
/tests/components/geniushub/ @manzanotti
/homeassistant/components/geo_json_events/ @exxamalte /homeassistant/components/geo_json_events/ @exxamalte
/tests/components/geo_json_events/ @exxamalte /tests/components/geo_json_events/ @exxamalte
/homeassistant/components/geo_location/ @home-assistant/core /homeassistant/components/geo_location/ @home-assistant/core
@ -631,8 +645,8 @@ build.json @home-assistant/supervisor
/tests/components/huum/ @frwickst /tests/components/huum/ @frwickst
/homeassistant/components/hvv_departures/ @vigonotion /homeassistant/components/hvv_departures/ @vigonotion
/tests/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion
/homeassistant/components/hydrawise/ @dknowles2 @ptcryan /homeassistant/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan
/tests/components/hydrawise/ @dknowles2 @ptcryan /tests/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan
/homeassistant/components/hyperion/ @dermotduffy /homeassistant/components/hyperion/ @dermotduffy
/tests/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy
/homeassistant/components/ialarm/ @RyuzakiKK /homeassistant/components/ialarm/ @RyuzakiKK
@ -691,6 +705,8 @@ build.json @home-assistant/supervisor
/tests/components/ios/ @robbiet480 /tests/components/ios/ @robbiet480
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard /homeassistant/components/iotawatt/ @gtdiehl @jyavenard
/tests/components/iotawatt/ @gtdiehl @jyavenard /tests/components/iotawatt/ @gtdiehl @jyavenard
/homeassistant/components/iotty/ @pburgio
/tests/components/iotty/ @pburgio
/homeassistant/components/iperf3/ @rohankapoorcom /homeassistant/components/iperf3/ @rohankapoorcom
/homeassistant/components/ipma/ @dgomes /homeassistant/components/ipma/ @dgomes
/tests/components/ipma/ @dgomes /tests/components/ipma/ @dgomes
@ -699,10 +715,14 @@ build.json @home-assistant/supervisor
/homeassistant/components/iqvia/ @bachya /homeassistant/components/iqvia/ @bachya
/tests/components/iqvia/ @bachya /tests/components/iqvia/ @bachya
/homeassistant/components/irish_rail_transport/ @ttroy50 /homeassistant/components/irish_rail_transport/ @ttroy50
/homeassistant/components/iron_os/ @tr4nt0r
/tests/components/iron_os/ @tr4nt0r
/homeassistant/components/isal/ @bdraco /homeassistant/components/isal/ @bdraco
/tests/components/isal/ @bdraco /tests/components/isal/ @bdraco
/homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair /homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair
/tests/components/islamic_prayer_times/ @engrbm87 @cpfair /tests/components/islamic_prayer_times/ @engrbm87 @cpfair
/homeassistant/components/israel_rail/ @shaiu
/tests/components/israel_rail/ @shaiu
/homeassistant/components/iss/ @DurgNomis-drol /homeassistant/components/iss/ @DurgNomis-drol
/tests/components/iss/ @DurgNomis-drol /tests/components/iss/ @DurgNomis-drol
/homeassistant/components/ista_ecotrend/ @tr4nt0r /homeassistant/components/ista_ecotrend/ @tr4nt0r
@ -737,8 +757,8 @@ build.json @home-assistant/supervisor
/tests/components/kitchen_sink/ @home-assistant/core /tests/components/kitchen_sink/ @home-assistant/core
/homeassistant/components/kmtronic/ @dgomes /homeassistant/components/kmtronic/ @dgomes
/tests/components/kmtronic/ @dgomes /tests/components/kmtronic/ @dgomes
/homeassistant/components/knocki/ @joostlek @jgatto1 /homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh
/tests/components/knocki/ @joostlek @jgatto1 /tests/components/knocki/ @joostlek @jgatto1 @JakeBosh
/homeassistant/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/knx/ @Julius2342 @farmio @marvin-w
/tests/components/knx/ @Julius2342 @farmio @marvin-w /tests/components/knx/ @Julius2342 @farmio @marvin-w
/homeassistant/components/kodi/ @OnFreund /homeassistant/components/kodi/ @OnFreund
@ -779,10 +799,14 @@ build.json @home-assistant/supervisor
/tests/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lidarr/ @tkdrob /homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob
/homeassistant/components/lifx/ @Djelibeybi
/tests/components/lifx/ @Djelibeybi
/homeassistant/components/light/ @home-assistant/core /homeassistant/components/light/ @home-assistant/core
/tests/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core
/homeassistant/components/linear_garage_door/ @IceBotYT /homeassistant/components/linear_garage_door/ @IceBotYT
/tests/components/linear_garage_door/ @IceBotYT /tests/components/linear_garage_door/ @IceBotYT
/homeassistant/components/linkplay/ @Velleman
/tests/components/linkplay/ @Velleman
/homeassistant/components/linux_battery/ @fabaff /homeassistant/components/linux_battery/ @fabaff
/homeassistant/components/litejet/ @joncar /homeassistant/components/litejet/ @joncar
/tests/components/litejet/ @joncar /tests/components/litejet/ @joncar
@ -802,8 +826,6 @@ build.json @home-assistant/supervisor
/tests/components/logbook/ @home-assistant/core /tests/components/logbook/ @home-assistant/core
/homeassistant/components/logger/ @home-assistant/core /homeassistant/components/logger/ @home-assistant/core
/tests/components/logger/ @home-assistant/core /tests/components/logger/ @home-assistant/core
/homeassistant/components/logi_circle/ @evanjd
/tests/components/logi_circle/ @evanjd
/homeassistant/components/london_underground/ @jpbede /homeassistant/components/london_underground/ @jpbede
/tests/components/london_underground/ @jpbede /tests/components/london_underground/ @jpbede
/homeassistant/components/lookin/ @ANMalko @bdraco /homeassistant/components/lookin/ @ANMalko @bdraco
@ -823,13 +845,16 @@ build.json @home-assistant/supervisor
/tests/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151 /tests/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151
/homeassistant/components/lyric/ @timmo001 /homeassistant/components/lyric/ @timmo001
/tests/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001
/homeassistant/components/mastodon/ @fabaff /homeassistant/components/madvr/ @iloveicedgreentea
/tests/components/madvr/ @iloveicedgreentea
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
/tests/components/mastodon/ @fabaff @andrew-codechimp
/homeassistant/components/matrix/ @PaarthShah /homeassistant/components/matrix/ @PaarthShah
/tests/components/matrix/ @PaarthShah /tests/components/matrix/ @PaarthShah
/homeassistant/components/matter/ @home-assistant/matter /homeassistant/components/matter/ @home-assistant/matter
/tests/components/matter/ @home-assistant/matter /tests/components/matter/ @home-assistant/matter
/homeassistant/components/mealie/ @joostlek /homeassistant/components/mealie/ @joostlek @andrew-codechimp
/tests/components/mealie/ @joostlek /tests/components/mealie/ @joostlek @andrew-codechimp
/homeassistant/components/meater/ @Sotolotl @emontnemery /homeassistant/components/meater/ @Sotolotl @emontnemery
/tests/components/meater/ @Sotolotl @emontnemery /tests/components/meater/ @Sotolotl @emontnemery
/homeassistant/components/medcom_ble/ @elafargue /homeassistant/components/medcom_ble/ @elafargue
@ -874,8 +899,6 @@ build.json @home-assistant/supervisor
/tests/components/moat/ @bdraco /tests/components/moat/ @bdraco
/homeassistant/components/mobile_app/ @home-assistant/core /homeassistant/components/mobile_app/ @home-assistant/core
/tests/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core
/homeassistant/components/modbus/ @janiversen
/tests/components/modbus/ @janiversen
/homeassistant/components/modem_callerid/ @tkdrob /homeassistant/components/modem_callerid/ @tkdrob
/tests/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob
/homeassistant/components/modern_forms/ @wonderslug /homeassistant/components/modern_forms/ @wonderslug
@ -945,6 +968,8 @@ build.json @home-assistant/supervisor
/tests/components/nfandroidtv/ @tkdrob /tests/components/nfandroidtv/ @tkdrob
/homeassistant/components/nibe_heatpump/ @elupus /homeassistant/components/nibe_heatpump/ @elupus
/tests/components/nibe_heatpump/ @elupus /tests/components/nibe_heatpump/ @elupus
/homeassistant/components/nice_go/ @IceBotYT
/tests/components/nice_go/ @IceBotYT
/homeassistant/components/nightscout/ @marciogranzotto /homeassistant/components/nightscout/ @marciogranzotto
/tests/components/nightscout/ @marciogranzotto /tests/components/nightscout/ @marciogranzotto
/homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nilu/ @hfurubotten
@ -987,8 +1012,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/ollama/ @synesthesiam /homeassistant/components/ollama/ @synesthesiam
/tests/components/ollama/ @synesthesiam /tests/components/ollama/ @synesthesiam
/homeassistant/components/ombi/ @larssont /homeassistant/components/ombi/ @larssont
/homeassistant/components/omnilogic/ @oliver84 @djtimca @gentoosu
/tests/components/omnilogic/ @oliver84 @djtimca @gentoosu
/homeassistant/components/onboarding/ @home-assistant/core /homeassistant/components/onboarding/ @home-assistant/core
/tests/components/onboarding/ @home-assistant/core /tests/components/onboarding/ @home-assistant/core
/homeassistant/components/oncue/ @bdraco @peterager /homeassistant/components/oncue/ @bdraco @peterager
@ -997,6 +1020,7 @@ build.json @home-assistant/supervisor
/tests/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP
/homeassistant/components/onewire/ @garbled1 @epenet /homeassistant/components/onewire/ @garbled1 @epenet
/tests/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet
/homeassistant/components/onkyo/ @arturpragacz
/homeassistant/components/onvif/ @hunterjm /homeassistant/components/onvif/ @hunterjm
/tests/components/onvif/ @hunterjm /tests/components/onvif/ @hunterjm
/homeassistant/components/open_meteo/ @frenck /homeassistant/components/open_meteo/ @frenck
@ -1032,8 +1056,8 @@ build.json @home-assistant/supervisor
/tests/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core
/homeassistant/components/ourgroceries/ @OnFreund /homeassistant/components/ourgroceries/ @OnFreund
/tests/components/ourgroceries/ @OnFreund /tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 /homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 /tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
/homeassistant/components/ovo_energy/ @timmo001 /homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas /homeassistant/components/p1_monitor/ @klaasnicolaas
@ -1185,8 +1209,8 @@ build.json @home-assistant/supervisor
/tests/components/rituals_perfume_genie/ @milanmeu @frenck /tests/components/rituals_perfume_genie/ @milanmeu @frenck
/homeassistant/components/rmvtransport/ @cgtobi /homeassistant/components/rmvtransport/ @cgtobi
/tests/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi
/homeassistant/components/roborock/ @humbertogontijo @Lash-L /homeassistant/components/roborock/ @Lash-L
/tests/components/roborock/ @humbertogontijo @Lash-L /tests/components/roborock/ @Lash-L
/homeassistant/components/roku/ @ctalkington /homeassistant/components/roku/ @ctalkington
/tests/components/roku/ @ctalkington /tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter /homeassistant/components/romy/ @xeniter
@ -1203,6 +1227,8 @@ build.json @home-assistant/supervisor
/tests/components/rtsp_to_webrtc/ @allenporter /tests/components/rtsp_to_webrtc/ @allenporter
/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
/homeassistant/components/russound_rio/ @noahhusby
/tests/components/russound_rio/ @noahhusby
/homeassistant/components/ruuvi_gateway/ @akx /homeassistant/components/ruuvi_gateway/ @akx
/tests/components/ruuvi_gateway/ @akx /tests/components/ruuvi_gateway/ @akx
/homeassistant/components/ruuvitag_ble/ @akx /homeassistant/components/ruuvitag_ble/ @akx
@ -1270,6 +1296,8 @@ build.json @home-assistant/supervisor
/tests/components/sighthound/ @robmarkcole /tests/components/sighthound/ @robmarkcole
/homeassistant/components/signal_messenger/ @bbernhard /homeassistant/components/signal_messenger/ @bbernhard
/tests/components/signal_messenger/ @bbernhard /tests/components/signal_messenger/ @bbernhard
/homeassistant/components/simplefin/ @scottg489 @jeeftor
/tests/components/simplefin/ @scottg489 @jeeftor
/homeassistant/components/simplepush/ @engrbm87 /homeassistant/components/simplepush/ @engrbm87
/tests/components/simplepush/ @engrbm87 /tests/components/simplepush/ @engrbm87
/homeassistant/components/simplisafe/ @bachya /homeassistant/components/simplisafe/ @bachya
@ -1421,6 +1449,8 @@ build.json @home-assistant/supervisor
/tests/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core /homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks /homeassistant/components/tesla_wall_connector/ @einarhauks
/tests/components/tesla_wall_connector/ @einarhauks /tests/components/tesla_wall_connector/ @einarhauks
/homeassistant/components/teslemetry/ @Bre77 /homeassistant/components/teslemetry/ @Bre77
@ -1459,8 +1489,8 @@ build.json @home-assistant/supervisor
/tests/components/tomorrowio/ @raman325 @lymanepp /tests/components/tomorrowio/ @raman325 @lymanepp
/homeassistant/components/totalconnect/ @austinmroczek /homeassistant/components/totalconnect/ @austinmroczek
/tests/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek
/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco @sdb9696 /homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696
/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco @sdb9696 /tests/components/tplink/ @rytilahti @bdraco @sdb9696
/homeassistant/components/tplink_omada/ @MarkGodwin /homeassistant/components/tplink_omada/ @MarkGodwin
/tests/components/tplink_omada/ @MarkGodwin /tests/components/tplink_omada/ @MarkGodwin
/homeassistant/components/traccar/ @ludeeus /homeassistant/components/traccar/ @ludeeus

View File

@ -12,7 +12,7 @@ ENV \
ARG QEMU_CPU ARG QEMU_CPU
# Install uv # Install uv
RUN pip3 install uv==0.2.13 RUN pip3 install uv==0.2.27
WORKDIR /usr/src WORKDIR /usr/src

View File

@ -4,7 +4,7 @@ coverage:
status: status:
project: project:
default: default:
target: 90 target: auto
threshold: 0.09 threshold: 0.09
required: required:
target: auto target: auto

View File

@ -28,6 +28,7 @@ from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN, REFRESH_TOKEN_EXPIRA
from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config
from .models import AuthFlowResult from .models import AuthFlowResult
from .providers import AuthProvider, LoginFlow, auth_provider_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config
from .providers.homeassistant import HassAuthProvider
EVENT_USER_ADDED = "user_added" EVENT_USER_ADDED = "user_added"
EVENT_USER_UPDATED = "user_updated" EVENT_USER_UPDATED = "user_updated"
@ -73,6 +74,13 @@ async def auth_manager_from_config(
key = (provider.type, provider.id) key = (provider.type, provider.id)
provider_hash[key] = provider provider_hash[key] = provider
if isinstance(provider, HassAuthProvider):
# Can be removed in 2026.7 with the legacy mode of homeassistant auth provider
# We need to initialize the provider to create the repair if needed as otherwise
# the provider will be initialized on first use, which could be rare as users
# don't frequently change auth settings
await provider.async_initialize()
if module_configs: if module_configs:
modules = await asyncio.gather( modules = await asyncio.gather(
*(auth_mfa_module_from_config(hass, config) for config in module_configs) *(auth_mfa_module_from_config(hass, config) for config in module_configs)
@ -355,15 +363,15 @@ class AuthManager:
local_only: bool | None = None, local_only: bool | None = None,
) -> None: ) -> None:
"""Update a user.""" """Update a user."""
kwargs: dict[str, Any] = {} kwargs: dict[str, Any] = {
attr_name: value
for attr_name, value in ( for attr_name, value in (
("name", name), ("name", name),
("group_ids", group_ids), ("group_ids", group_ids),
("local_only", local_only), ("local_only", local_only),
): )
if value is not None: if value is not None
kwargs[attr_name] = value }
await self._store.async_update_user(user, **kwargs) await self._store.async_update_user(user, **kwargs)
if is_active is not None: if is_active is not None:
@ -374,6 +382,13 @@ class AuthManager:
self.hass.bus.async_fire(EVENT_USER_UPDATED, {"user_id": user.id}) self.hass.bus.async_fire(EVENT_USER_UPDATED, {"user_id": user.id})
@callback
def async_update_user_credentials_data(
self, credentials: models.Credentials, data: dict[str, Any]
) -> None:
"""Update credentials data."""
self._store.async_update_user_credentials_data(credentials, data=data)
async def async_activate_user(self, user: models.User) -> None: async def async_activate_user(self, user: models.User) -> None:
"""Activate a user.""" """Activate a user."""
await self._store.async_activate_user(user) await self._store.async_activate_user(user)

View File

@ -105,14 +105,18 @@ class AuthStore:
"perm_lookup": self._perm_lookup, "perm_lookup": self._perm_lookup,
} }
for attr_name, value in ( kwargs.update(
("is_owner", is_owner), {
("is_active", is_active), attr_name: value
("local_only", local_only), for attr_name, value in (
("system_generated", system_generated), ("is_owner", is_owner),
): ("is_active", is_active),
if value is not None: ("local_only", local_only),
kwargs[attr_name] = value ("system_generated", system_generated),
)
if value is not None
}
)
new_user = models.User(**kwargs) new_user = models.User(**kwargs)
@ -296,6 +300,14 @@ class AuthStore:
refresh_token.expire_at = None refresh_token.expire_at = None
self._async_schedule_save() self._async_schedule_save()
@callback
def async_update_user_credentials_data(
self, credentials: models.Credentials, data: dict[str, Any]
) -> None:
"""Update credentials data."""
credentials.data = data
self._async_schedule_save()
async def async_load(self) -> None: # noqa: C901 async def async_load(self) -> None: # noqa: C901
"""Load the users.""" """Load the users."""
if self._loaded: if self._loaded:

View File

@ -18,9 +18,12 @@ from homeassistant.const import (
EVENT_THEMES_UPDATED, EVENT_THEMES_UPDATED,
) )
from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
from homeassistant.helpers.category_registry import EVENT_CATEGORY_REGISTRY_UPDATED
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.helpers.floor_registry import EVENT_FLOOR_REGISTRY_UPDATED
from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED
from homeassistant.helpers.label_registry import EVENT_LABEL_REGISTRY_UPDATED
from homeassistant.util.event_type import EventType from homeassistant.util.event_type import EventType
# These are events that do not contain any sensitive data # These are events that do not contain any sensitive data
@ -41,4 +44,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
EVENT_SHOPPING_LIST_UPDATED, EVENT_SHOPPING_LIST_UPDATED,
EVENT_STATE_CHANGED, EVENT_STATE_CHANGED,
EVENT_THEMES_UPDATED, EVENT_THEMES_UPDATED,
EVENT_LABEL_REGISTRY_UPDATED,
EVENT_CATEGORY_REGISTRY_UPDATED,
EVENT_FLOOR_REGISTRY_UPDATED,
} }

View File

@ -14,6 +14,7 @@ import voluptuous as vol
from homeassistant.const import CONF_ID from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from ..models import AuthFlowResult, Credentials, UserMeta from ..models import AuthFlowResult, Credentials, UserMeta
@ -54,6 +55,27 @@ class InvalidUser(HomeAssistantError):
Will not be raised when validating authentication. Will not be raised when validating authentication.
""" """
def __init__(
self,
*args: object,
translation_key: str | None = None,
translation_placeholders: dict[str, str] | None = None,
) -> None:
"""Initialize exception."""
super().__init__(
*args,
translation_domain="auth",
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)
class InvalidUsername(InvalidUser):
"""Raised when invalid username is specified.
Will not be raised when validating authentication.
"""
class Data: class Data:
"""Hold the user data.""" """Hold the user data."""
@ -67,13 +89,15 @@ class Data:
self._data: dict[str, list[dict[str, str]]] | None = None self._data: dict[str, list[dict[str, str]]] | None = None
# Legacy mode will allow usernames to start/end with whitespace # Legacy mode will allow usernames to start/end with whitespace
# and will compare usernames case-insensitive. # and will compare usernames case-insensitive.
# Remove in 2020 or when we launch 1.0. # Deprecated in June 2019 and will be removed in 2026.7
self.is_legacy = False self.is_legacy = False
@callback @callback
def normalize_username(self, username: str) -> str: def normalize_username(
self, username: str, *, force_normalize: bool = False
) -> str:
"""Normalize a username based on the mode.""" """Normalize a username based on the mode."""
if self.is_legacy: if self.is_legacy and not force_normalize:
return username return username
return username.strip().casefold() return username.strip().casefold()
@ -83,44 +107,49 @@ class Data:
if (data := await self._store.async_load()) is None: if (data := await self._store.async_load()) is None:
data = cast(dict[str, list[dict[str, str]]], {"users": []}) data = cast(dict[str, list[dict[str, str]]], {"users": []})
seen: set[str] = set() self._async_check_for_not_normalized_usernames(data)
self._data = data
@callback
def _async_check_for_not_normalized_usernames(
self, data: dict[str, list[dict[str, str]]]
) -> None:
not_normalized_usernames: set[str] = set()
for user in data["users"]: for user in data["users"]:
username = user["username"] username = user["username"]
# check if we have duplicates if self.normalize_username(username, force_normalize=True) != username:
if (folded := username.casefold()) in seen:
self.is_legacy = True
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
( (
"Home Assistant auth provider is running in legacy mode " "Home Assistant auth provider is running in legacy mode "
"because we detected usernames that are case-insensitive" "because we detected usernames that are normalized (lowercase and without spaces)."
"equivalent. Please change the username: '%s'." " Please change the username: '%s'."
), ),
username, username,
) )
not_normalized_usernames.add(username)
break if not_normalized_usernames:
self.is_legacy = True
seen.add(folded) ir.async_create_issue(
self.hass,
# check if we have unstripped usernames "auth",
if username != username.strip(): "homeassistant_provider_not_normalized_usernames",
self.is_legacy = True breaks_in_ha_version="2026.7.0",
is_fixable=False,
logging.getLogger(__name__).warning( severity=ir.IssueSeverity.WARNING,
( translation_key="homeassistant_provider_not_normalized_usernames",
"Home Assistant auth provider is running in legacy mode " translation_placeholders={
"because we detected usernames that start or end in a " "usernames": f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
"space. Please change the username: '%s'." },
), learn_more_url="homeassistant://config/users",
username, )
) else:
self.is_legacy = False
break ir.async_delete_issue(
self.hass, "auth", "homeassistant_provider_not_normalized_usernames"
self._data = data )
@property @property
def users(self) -> list[dict[str, str]]: def users(self) -> list[dict[str, str]]:
@ -162,13 +191,11 @@ class Data:
return hashed return hashed
def add_auth(self, username: str, password: str) -> None: def add_auth(self, username: str, password: str) -> None:
"""Add a new authenticated user/pass.""" """Add a new authenticated user/pass.
username = self.normalize_username(username)
if any( Raises InvalidUsername if the new username is invalid.
self.normalize_username(user["username"]) == username for user in self.users """
): self._validate_new_username(username)
raise InvalidUser
self.users.append( self.users.append(
{ {
@ -189,7 +216,7 @@ class Data:
break break
if index is None: if index is None:
raise InvalidUser raise InvalidUser(translation_key="user_not_found")
self.users.pop(index) self.users.pop(index)
@ -205,7 +232,50 @@ class Data:
user["password"] = self.hash_password(new_password, True).decode() user["password"] = self.hash_password(new_password, True).decode()
break break
else: else:
raise InvalidUser raise InvalidUser(translation_key="user_not_found")
@callback
def _validate_new_username(self, new_username: str) -> None:
"""Validate that username is normalized and unique.
Raises InvalidUsername if the new username is invalid.
"""
normalized_username = self.normalize_username(
new_username, force_normalize=True
)
if normalized_username != new_username:
raise InvalidUsername(
translation_key="username_not_normalized",
translation_placeholders={"new_username": new_username},
)
if any(
self.normalize_username(user["username"]) == normalized_username
for user in self.users
):
raise InvalidUsername(
translation_key="username_already_exists",
translation_placeholders={"username": new_username},
)
@callback
def change_username(self, username: str, new_username: str) -> None:
"""Update the username.
Raises InvalidUser if user cannot be found.
Raises InvalidUsername if the new username is invalid.
"""
username = self.normalize_username(username)
self._validate_new_username(new_username)
for user in self.users:
if self.normalize_username(user["username"]) == username:
user["username"] = new_username
assert self._data is not None
self._async_check_for_not_normalized_usernames(self._data)
break
else:
raise InvalidUser(translation_key="user_not_found")
async def async_save(self) -> None: async def async_save(self) -> None:
"""Save data.""" """Save data."""
@ -278,6 +348,20 @@ class HassAuthProvider(AuthProvider):
) )
await self.data.async_save() await self.data.async_save()
async def async_change_username(
self, credential: Credentials, new_username: str
) -> None:
"""Validate new username and change it including updating credentials object."""
if self.data is None:
await self.async_initialize()
assert self.data is not None
self.data.change_username(credential.data["username"], new_username)
self.hass.auth.async_update_user_credentials_data(
credential, {**credential.data, "username": new_username}
)
await self.data.async_save()
async def async_get_or_create_credentials( async def async_get_or_create_credentials(
self, flow_result: Mapping[str, str] self, flow_result: Mapping[str, str]
) -> Credentials: ) -> Credentials:

View File

@ -8,6 +8,8 @@ import glob
from http.client import HTTPConnection from http.client import HTTPConnection
import importlib import importlib
import os import os
from pathlib import Path
from ssl import SSLContext
import sys import sys
import threading import threading
import time import time
@ -143,6 +145,78 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = (
strict_core=False, strict_core=False,
skip_for_tests=True, skip_for_tests=True,
), ),
BlockingCall(
original_func=SSLContext.load_default_certs,
object=SSLContext,
function="load_default_certs",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=SSLContext.load_verify_locations,
object=SSLContext,
function="load_verify_locations",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=SSLContext.load_cert_chain,
object=SSLContext,
function="load_cert_chain",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.open,
object=Path,
function="open",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.read_text,
object=Path,
function="read_text",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.read_bytes,
object=Path,
function="read_bytes",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.write_text,
object=Path,
function="write_text",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.write_bytes,
object=Path,
function="write_bytes",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
) )

View File

@ -8,7 +8,7 @@ import contextlib
from functools import partial from functools import partial
from itertools import chain from itertools import chain
import logging import logging
import logging.handlers from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
import mimetypes import mimetypes
from operator import contains, itemgetter from operator import contains, itemgetter
import os import os
@ -88,7 +88,7 @@ from .helpers import (
) )
from .helpers.dispatcher import async_dispatcher_send_internal from .helpers.dispatcher import async_dispatcher_send_internal
from .helpers.storage import get_internal_store_manager from .helpers.storage import get_internal_store_manager
from .helpers.system_info import async_get_system_info from .helpers.system_info import async_get_system_info, is_official_image
from .helpers.typing import ConfigType from .helpers.typing import ConfigType
from .setup import ( from .setup import (
# _setup_started is marked as protected to make it clear # _setup_started is marked as protected to make it clear
@ -104,7 +104,7 @@ from .setup import (
from .util.async_ import create_eager_task from .util.async_ import create_eager_task
from .util.hass_dict import HassKey from .util.hass_dict import HassKey
from .util.logging import async_activate_log_queue_handler from .util.logging import async_activate_log_queue_handler
from .util.package import async_get_user_site, is_virtual_env from .util.package import async_get_user_site, is_docker_env, is_virtual_env
with contextlib.suppress(ImportError): with contextlib.suppress(ImportError):
# Ensure anyio backend is imported to avoid it being imported in the event loop # Ensure anyio backend is imported to avoid it being imported in the event loop
@ -223,8 +223,10 @@ CRITICAL_INTEGRATIONS = {
SETUP_ORDER = ( SETUP_ORDER = (
# Load logging and http deps as soon as possible # Load logging and http deps as soon as possible
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS), ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS),
# Setup frontend and recorder # Setup frontend
("frontend, recorder", {*FRONTEND_INTEGRATIONS, *RECORDER_INTEGRATIONS}), ("frontend", FRONTEND_INTEGRATIONS),
# Setup recorder
("recorder", RECORDER_INTEGRATIONS),
# Start up debuggers. Start these first in case they want to wait. # Start up debuggers. Start these first in case they want to wait.
("debugger", DEBUGGER_INTEGRATIONS), ("debugger", DEBUGGER_INTEGRATIONS),
) )
@ -257,12 +259,12 @@ async def async_setup_hass(
) -> core.HomeAssistant | None: ) -> core.HomeAssistant | None:
"""Set up Home Assistant.""" """Set up Home Assistant."""
def create_hass() -> core.HomeAssistant: async def create_hass() -> core.HomeAssistant:
"""Create the hass object and do basic setup.""" """Create the hass object and do basic setup."""
hass = core.HomeAssistant(runtime_config.config_dir) hass = core.HomeAssistant(runtime_config.config_dir)
loader.async_setup(hass) loader.async_setup(hass)
async_enable_logging( await async_enable_logging(
hass, hass,
runtime_config.verbose, runtime_config.verbose,
runtime_config.log_rotate_days, runtime_config.log_rotate_days,
@ -287,7 +289,7 @@ async def async_setup_hass(
async with hass.timeout.async_timeout(10): async with hass.timeout.async_timeout(10):
await hass.async_stop() await hass.async_stop()
hass = create_hass() hass = await create_hass()
if runtime_config.skip_pip or runtime_config.skip_pip_packages: if runtime_config.skip_pip or runtime_config.skip_pip_packages:
_LOGGER.warning( _LOGGER.warning(
@ -326,13 +328,13 @@ async def async_setup_hass(
if config_dict is None: if config_dict is None:
recovery_mode = True recovery_mode = True
await stop_hass(hass) await stop_hass(hass)
hass = create_hass() hass = await create_hass()
elif not basic_setup_success: elif not basic_setup_success:
_LOGGER.warning("Unable to set up core integrations. Activating recovery mode") _LOGGER.warning("Unable to set up core integrations. Activating recovery mode")
recovery_mode = True recovery_mode = True
await stop_hass(hass) await stop_hass(hass)
hass = create_hass() hass = await create_hass()
elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS): elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS):
_LOGGER.warning( _LOGGER.warning(
@ -345,7 +347,7 @@ async def async_setup_hass(
recovery_mode = True recovery_mode = True
await stop_hass(hass) await stop_hass(hass)
hass = create_hass() hass = await create_hass()
if old_logging: if old_logging:
hass.data[DATA_LOGGING] = old_logging hass.data[DATA_LOGGING] = old_logging
@ -407,6 +409,10 @@ def _init_blocking_io_modules_in_executor() -> None:
# Initialize the mimetypes module to avoid blocking calls # Initialize the mimetypes module to avoid blocking calls
# to the filesystem to load the mime.types file. # to the filesystem to load the mime.types file.
mimetypes.init() mimetypes.init()
# Initialize is_official_image and is_docker_env to avoid blocking calls
# to the filesystem.
is_official_image()
is_docker_env()
async def async_load_base_functionality(hass: core.HomeAssistant) -> None: async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
@ -523,8 +529,7 @@ async def async_from_config_dict(
return hass return hass
@core.callback async def async_enable_logging(
def async_enable_logging(
hass: core.HomeAssistant, hass: core.HomeAssistant,
verbose: bool = False, verbose: bool = False,
log_rotate_days: int | None = None, log_rotate_days: int | None = None,
@ -581,10 +586,10 @@ def async_enable_logging(
logging.getLogger("aiohttp.access").setLevel(logging.WARNING) logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING)
sys.excepthook = lambda *args: logging.getLogger(None).exception( sys.excepthook = lambda *args: logging.getLogger().exception(
"Uncaught exception", exc_info=args "Uncaught exception", exc_info=args
) )
threading.excepthook = lambda args: logging.getLogger(None).exception( threading.excepthook = lambda args: logging.getLogger().exception(
"Uncaught thread exception", "Uncaught thread exception",
exc_info=( # type: ignore[arg-type] exc_info=( # type: ignore[arg-type]
args.exc_type, args.exc_type,
@ -607,28 +612,13 @@ def async_enable_logging(
if (err_path_exists and os.access(err_log_path, os.W_OK)) or ( if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
not err_path_exists and os.access(err_dir, os.W_OK) not err_path_exists and os.access(err_dir, os.W_OK)
): ):
err_handler: ( err_handler = await hass.async_add_executor_job(
logging.handlers.RotatingFileHandler _create_log_file, err_log_path, log_rotate_days
| logging.handlers.TimedRotatingFileHandler
) )
if log_rotate_days:
err_handler = logging.handlers.TimedRotatingFileHandler(
err_log_path, when="midnight", backupCount=log_rotate_days
)
else:
err_handler = _RotatingFileHandlerWithoutShouldRollOver(
err_log_path, backupCount=1
)
try:
err_handler.doRollover()
except OSError as err:
_LOGGER.error("Error rolling over log file: %s", err)
err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
logger = logging.getLogger("") logger = logging.getLogger()
logger.addHandler(err_handler) logger.addHandler(err_handler)
logger.setLevel(logging.INFO if verbose else logging.WARNING) logger.setLevel(logging.INFO if verbose else logging.WARNING)
@ -640,7 +630,29 @@ def async_enable_logging(
async_activate_log_queue_handler(hass) async_activate_log_queue_handler(hass)
class _RotatingFileHandlerWithoutShouldRollOver(logging.handlers.RotatingFileHandler): def _create_log_file(
err_log_path: str, log_rotate_days: int | None
) -> RotatingFileHandler | TimedRotatingFileHandler:
"""Create log file and do roll over."""
err_handler: RotatingFileHandler | TimedRotatingFileHandler
if log_rotate_days:
err_handler = TimedRotatingFileHandler(
err_log_path, when="midnight", backupCount=log_rotate_days
)
else:
err_handler = _RotatingFileHandlerWithoutShouldRollOver(
err_log_path, backupCount=1
)
try:
err_handler.doRollover()
except OSError as err:
_LOGGER.error("Error rolling over log file: %s", err)
return err_handler
class _RotatingFileHandlerWithoutShouldRollOver(RotatingFileHandler):
"""RotatingFileHandler that does not check if it should roll over on every log.""" """RotatingFileHandler that does not check if it should roll over on every log."""
def shouldRollover(self, record: logging.LogRecord) -> bool: def shouldRollover(self, record: logging.LogRecord) -> bool:
@ -895,7 +907,13 @@ async def _async_resolve_domains_to_setup(
await asyncio.gather(*resolve_dependencies_tasks) await asyncio.gather(*resolve_dependencies_tasks)
for itg in integrations_to_process: for itg in integrations_to_process:
for dep in itg.all_dependencies: try:
all_deps = itg.all_dependencies
except RuntimeError:
# Integration.all_dependencies raises RuntimeError if
# dependencies could not be resolved
continue
for dep in all_deps:
if dep in domains_to_setup: if dep in domains_to_setup:
continue continue
domains_to_setup.add(dep) domains_to_setup.add(dep)

View File

@ -1,5 +0,0 @@
{
"domain": "asterisk",
"name": "Asterisk",
"integrations": ["asterisk_cdr", "asterisk_mbox"]
}

View File

@ -1,5 +1,5 @@
{ {
"domain": "logitech", "domain": "logitech",
"name": "Logitech", "name": "Logitech",
"integrations": ["harmony", "ue_smart_radio", "squeezebox"] "integrations": ["harmony", "squeezebox"]
} }

View File

@ -1,5 +1,5 @@
{ {
"domain": "tesla", "domain": "tesla",
"name": "Tesla", "name": "Tesla",
"integrations": ["powerwall", "tesla_wall_connector"] "integrations": ["powerwall", "tesla_wall_connector", "tesla_fleet"]
} }

View File

@ -5,13 +5,6 @@ from __future__ import annotations
from typing import cast from typing import cast
from jaraco.abode.devices.sensor import BinarySensor from jaraco.abode.devices.sensor import BinarySensor
from jaraco.abode.helpers.constants import (
TYPE_CONNECTIVITY,
TYPE_MOISTURE,
TYPE_MOTION,
TYPE_OCCUPANCY,
TYPE_OPENING,
)
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
@ -34,11 +27,11 @@ async def async_setup_entry(
data: AbodeSystem = hass.data[DOMAIN] data: AbodeSystem = hass.data[DOMAIN]
device_types = [ device_types = [
TYPE_CONNECTIVITY, "connectivity",
TYPE_MOISTURE, "moisture",
TYPE_MOTION, "motion",
TYPE_OCCUPANCY, "occupancy",
TYPE_OPENING, "door",
] ]
async_add_entities( async_add_entities(

View File

@ -8,7 +8,6 @@ from typing import Any, cast
from jaraco.abode.devices.base import Device from jaraco.abode.devices.base import Device
from jaraco.abode.devices.camera import Camera as AbodeCam from jaraco.abode.devices.camera import Camera as AbodeCam
from jaraco.abode.helpers import timeline from jaraco.abode.helpers import timeline
from jaraco.abode.helpers.constants import TYPE_CAMERA
import requests import requests
from requests.models import Response from requests.models import Response
@ -34,7 +33,7 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
AbodeCamera(data, device, timeline.CAPTURE_IMAGE) AbodeCamera(data, device, timeline.CAPTURE_IMAGE)
for device in data.abode.get_devices(generic_type=TYPE_CAMERA) for device in data.abode.get_devices(generic_type="camera")
) )

View File

@ -3,7 +3,6 @@
from typing import Any from typing import Any
from jaraco.abode.devices.cover import Cover from jaraco.abode.devices.cover import Cover
from jaraco.abode.helpers.constants import TYPE_COVER
from homeassistant.components.cover import CoverEntity from homeassistant.components.cover import CoverEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -23,7 +22,7 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
AbodeCover(data, device) AbodeCover(data, device)
for device in data.abode.get_devices(generic_type=TYPE_COVER) for device in data.abode.get_devices(generic_type="cover")
) )

View File

@ -105,7 +105,7 @@ class AbodeAutomation(AbodeEntity):
super().__init__(data) super().__init__(data)
self._automation = automation self._automation = automation
self._attr_name = automation.name self._attr_name = automation.name
self._attr_unique_id = automation.automation_id self._attr_unique_id = automation.id
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
"type": "CUE automation", "type": "CUE automation",
} }

View File

@ -6,7 +6,6 @@ from math import ceil
from typing import Any from typing import Any
from jaraco.abode.devices.light import Light from jaraco.abode.devices.light import Light
from jaraco.abode.helpers.constants import TYPE_LIGHT
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@ -36,7 +35,7 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
AbodeLight(data, device) AbodeLight(data, device)
for device in data.abode.get_devices(generic_type=TYPE_LIGHT) for device in data.abode.get_devices(generic_type="light")
) )

View File

@ -3,7 +3,6 @@
from typing import Any from typing import Any
from jaraco.abode.devices.lock import Lock from jaraco.abode.devices.lock import Lock
from jaraco.abode.helpers.constants import TYPE_LOCK
from homeassistant.components.lock import LockEntity from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -23,7 +22,7 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
AbodeLock(data, device) AbodeLock(data, device)
for device in data.abode.get_devices(generic_type=TYPE_LOCK) for device in data.abode.get_devices(generic_type="lock")
) )

View File

@ -9,5 +9,5 @@
}, },
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["jaraco.abode", "lomond"], "loggers": ["jaraco.abode", "lomond"],
"requirements": ["jaraco.abode==3.3.0", "jaraco.functools==3.9.0"] "requirements": ["jaraco.abode==5.2.1"]
} }

View File

@ -7,15 +7,6 @@ from dataclasses import dataclass
from typing import cast from typing import cast
from jaraco.abode.devices.sensor import Sensor from jaraco.abode.devices.sensor import Sensor
from jaraco.abode.helpers.constants import (
HUMI_STATUS_KEY,
LUX_STATUS_KEY,
STATUSES_KEY,
TEMP_STATUS_KEY,
TYPE_SENSOR,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
)
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -32,8 +23,8 @@ from .const import DOMAIN
from .entity import AbodeDevice from .entity import AbodeDevice
ABODE_TEMPERATURE_UNIT_HA_UNIT = { ABODE_TEMPERATURE_UNIT_HA_UNIT = {
UNIT_FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, "°F": UnitOfTemperature.FAHRENHEIT,
UNIT_CELSIUS: UnitOfTemperature.CELSIUS, "°C": UnitOfTemperature.CELSIUS,
} }
@ -47,7 +38,7 @@ class AbodeSensorDescription(SensorEntityDescription):
SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = ( SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
AbodeSensorDescription( AbodeSensorDescription(
key=TEMP_STATUS_KEY, key="temperature",
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[ native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[
device.temp_unit device.temp_unit
@ -55,13 +46,13 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
value_fn=lambda device: cast(float, device.temp), value_fn=lambda device: cast(float, device.temp),
), ),
AbodeSensorDescription( AbodeSensorDescription(
key=HUMI_STATUS_KEY, key="humidity",
device_class=SensorDeviceClass.HUMIDITY, device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement_fn=lambda _: PERCENTAGE, native_unit_of_measurement_fn=lambda _: PERCENTAGE,
value_fn=lambda device: cast(float, device.humidity), value_fn=lambda device: cast(float, device.humidity),
), ),
AbodeSensorDescription( AbodeSensorDescription(
key=LUX_STATUS_KEY, key="lux",
device_class=SensorDeviceClass.ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement_fn=lambda _: LIGHT_LUX, native_unit_of_measurement_fn=lambda _: LIGHT_LUX,
value_fn=lambda device: cast(float, device.lux), value_fn=lambda device: cast(float, device.lux),
@ -78,8 +69,8 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
AbodeSensor(data, device, description) AbodeSensor(data, device, description)
for description in SENSOR_TYPES for description in SENSOR_TYPES
for device in data.abode.get_devices(generic_type=TYPE_SENSOR) for device in data.abode.get_devices(generic_type="sensor")
if description.key in device.get_value(STATUSES_KEY) if description.key in device.get_value("statuses")
) )

View File

@ -5,7 +5,6 @@ from __future__ import annotations
from typing import Any, cast from typing import Any, cast
from jaraco.abode.devices.switch import Switch from jaraco.abode.devices.switch import Switch
from jaraco.abode.helpers.constants import TYPE_SWITCH, TYPE_VALVE
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -17,7 +16,7 @@ from . import AbodeSystem
from .const import DOMAIN from .const import DOMAIN
from .entity import AbodeAutomation, AbodeDevice from .entity import AbodeAutomation, AbodeDevice
DEVICE_TYPES = [TYPE_SWITCH, TYPE_VALVE] DEVICE_TYPES = ["switch", "valve"]
async def async_setup_entry( async def async_setup_entry(
@ -89,4 +88,4 @@ class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if the automation is enabled.""" """Return True if the automation is enabled."""
return bool(self._automation.is_enabled) return bool(self._automation.enabled)

View File

@ -9,7 +9,10 @@ from typing import Any
import serial import serial
import voluptuous as vol import voluptuous as vol
from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.components.switch import (
PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA,
SwitchEntity,
)
from homeassistant.const import ( from homeassistant.const import (
CONF_FILENAME, CONF_FILENAME,
CONF_NAME, CONF_NAME,
@ -38,7 +41,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_FILENAME): cv.isdevice, vol.Required(CONF_FILENAME): cv.isdevice,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
@ -78,7 +81,7 @@ class AcerSwitch(SwitchEntity):
write_timeout: int, write_timeout: int,
) -> None: ) -> None:
"""Init of the Acer projector.""" """Init of the Acer projector."""
self.ser = serial.Serial( self.serial = serial.Serial(
port=serial_port, timeout=timeout, write_timeout=write_timeout port=serial_port, timeout=timeout, write_timeout=write_timeout
) )
self._serial_port = serial_port self._serial_port = serial_port
@ -96,16 +99,16 @@ class AcerSwitch(SwitchEntity):
# was disconnected during runtime. # was disconnected during runtime.
# This way the projector can be reconnected and will still work # This way the projector can be reconnected and will still work
try: try:
if not self.ser.is_open: if not self.serial.is_open:
self.ser.open() self.serial.open()
self.ser.write(msg.encode("utf-8")) self.serial.write(msg.encode("utf-8"))
# Size is an experience value there is no real limit. # Size is an experience value there is no real limit.
# AFAIK there is no limit and no end character so we will usually # AFAIK there is no limit and no end character so we will usually
# need to wait for timeout # need to wait for timeout
ret = self.ser.read_until(size=20).decode("utf-8") ret = self.serial.read_until(size=20).decode("utf-8")
except serial.SerialException: except serial.SerialException:
_LOGGER.error("Problem communicating with %s", self._serial_port) _LOGGER.error("Problem communicating with %s", self._serial_port)
self.ser.close() self.serial.close()
return ret return ret
def _write_read_format(self, msg: str) -> str: def _write_read_format(self, msg: str) -> str:

View File

@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.device_tracker import ( from homeassistant.components.device_tracker import (
DOMAIN, DOMAIN,
PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA,
DeviceScanner, DeviceScanner,
) )
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
@ -23,7 +23,7 @@ from .model import Device
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA: Final = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_HOST): cv.string, vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_PASSWORD): cv.string,

View File

@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["adguardhome"], "loggers": ["adguardhome"],
"requirements": ["adguardhome==0.6.3"] "requirements": ["adguardhome==0.7.0"]
} }

View File

@ -136,7 +136,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Tuple to hold data needed for notification # Tuple to hold data needed for notification
NotificationItem = namedtuple( NotificationItem = namedtuple( # noqa: PYI024
"NotificationItem", "hnotify huser name plc_datatype callback" "NotificationItem", "hnotify huser name plc_datatype callback"
) )

View File

@ -7,7 +7,7 @@ import voluptuous as vol
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DEVICE_CLASSES_SCHEMA, DEVICE_CLASSES_SCHEMA,
PLATFORM_SCHEMA, PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA,
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
) )
@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE, AdsEntity from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE, AdsEntity
DEFAULT_NAME = "ADS binary sensor" DEFAULT_NAME = "ADS binary sensor"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_ADS_VAR): cv.string, vol.Required(CONF_ADS_VAR): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,

View File

@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.cover import ( from homeassistant.components.cover import (
ATTR_POSITION, ATTR_POSITION,
DEVICE_CLASSES_SCHEMA, DEVICE_CLASSES_SCHEMA,
PLATFORM_SCHEMA, PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA,
CoverEntity, CoverEntity,
CoverEntityFeature, CoverEntityFeature,
) )
@ -36,7 +36,7 @@ CONF_ADS_VAR_OPEN = "adsvar_open"
CONF_ADS_VAR_CLOSE = "adsvar_close" CONF_ADS_VAR_CLOSE = "adsvar_close"
CONF_ADS_VAR_STOP = "adsvar_stop" CONF_ADS_VAR_STOP = "adsvar_stop"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend(
{ {
vol.Optional(CONF_ADS_VAR): cv.string, vol.Optional(CONF_ADS_VAR): cv.string,
vol.Optional(CONF_ADS_VAR_POSITION): cv.string, vol.Optional(CONF_ADS_VAR_POSITION): cv.string,

View File

@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
PLATFORM_SCHEMA, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode, ColorMode,
LightEntity, LightEntity,
) )
@ -29,7 +29,7 @@ from . import (
) )
DEFAULT_NAME = "ADS Light" DEFAULT_NAME = "ADS Light"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_ADS_VAR): cv.string, vol.Required(CONF_ADS_VAR): cv.string,
vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string, vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string,

View File

@ -4,7 +4,10 @@ from __future__ import annotations
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@ -22,7 +25,7 @@ from . import (
) )
DEFAULT_NAME = "ADS sensor" DEFAULT_NAME = "ADS sensor"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_ADS_VAR): cv.string, vol.Required(CONF_ADS_VAR): cv.string,
vol.Optional(CONF_ADS_FACTOR): cv.positive_int, vol.Optional(CONF_ADS_FACTOR): cv.positive_int,

View File

@ -7,7 +7,10 @@ from typing import Any
import pyads import pyads
import voluptuous as vol import voluptuous as vol
from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.components.switch import (
PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA,
SwitchEntity,
)
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@ -18,7 +21,7 @@ from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE, AdsEntity
DEFAULT_NAME = "ADS Switch" DEFAULT_NAME = "ADS Switch"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_ADS_VAR): cv.string, vol.Required(CONF_ADS_VAR): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,

View File

@ -206,7 +206,8 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC Mode and State.""" """Set the HVAC Mode and State."""
if hvac_mode == HVACMode.OFF: if hvac_mode == HVACMode.OFF:
return await self.async_turn_off() await self.async_turn_off()
return
if hvac_mode == HVACMode.HEAT_COOL and self.preset_mode != ADVANTAGE_AIR_MYAUTO: if hvac_mode == HVACMode.HEAT_COOL and self.preset_mode != ADVANTAGE_AIR_MYAUTO:
raise ServiceValidationError("Heat/Cool is not supported in this mode") raise ServiceValidationError("Heat/Cool is not supported in this mode")
await self.async_update_ac( await self.async_update_ac(

View File

@ -7,14 +7,35 @@ from typing import Any
from aemet_opendata.helpers import dict_nested_value from aemet_opendata.helpers import dict_nested_value
from homeassistant.components.weather import Forecast from homeassistant.components.weather import Forecast
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN
from .coordinator import WeatherUpdateCoordinator from .coordinator import WeatherUpdateCoordinator
class AemetEntity(CoordinatorEntity[WeatherUpdateCoordinator]): class AemetEntity(CoordinatorEntity[WeatherUpdateCoordinator]):
"""Define an AEMET entity.""" """Define an AEMET entity."""
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__(
self,
coordinator: WeatherUpdateCoordinator,
name: str,
unique_id: str,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
name=name,
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, unique_id)},
manufacturer="AEMET",
model="Forecast",
)
def get_aemet_forecast(self, forecast_mode: str) -> list[Forecast]: def get_aemet_forecast(self, forecast_mode: str) -> list[Forecast]:
"""Return AEMET entity forecast by mode.""" """Return AEMET entity forecast by mode."""
return self.coordinator.data["forecast"][forecast_mode] return self.coordinator.data["forecast"][forecast_mode]

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aemet", "documentation": "https://www.home-assistant.io/integrations/aemet",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aemet_opendata"], "loggers": ["aemet_opendata"],
"requirements": ["AEMET-OpenData==0.5.2"] "requirements": ["AEMET-OpenData==0.5.4"]
} }

View File

@ -43,7 +43,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
DEGREE, DEGREE,
PERCENTAGE, PERCENTAGE,
@ -86,7 +85,6 @@ from .const import (
ATTR_API_WIND_BEARING, ATTR_API_WIND_BEARING,
ATTR_API_WIND_MAX_SPEED, ATTR_API_WIND_MAX_SPEED,
ATTR_API_WIND_SPEED, ATTR_API_WIND_SPEED,
ATTRIBUTION,
CONDITIONS_MAP, CONDITIONS_MAP,
) )
from .coordinator import WeatherUpdateCoordinator from .coordinator import WeatherUpdateCoordinator
@ -366,12 +364,15 @@ async def async_setup_entry(
name = domain_data.name name = domain_data.name
coordinator = domain_data.coordinator coordinator = domain_data.coordinator
unique_id = config_entry.unique_id
assert unique_id is not None
async_add_entities( async_add_entities(
AemetSensor( AemetSensor(
name, name,
coordinator, coordinator,
description, description,
config_entry, unique_id,
) )
for description in FORECAST_SENSORS + WEATHER_SENSORS for description in FORECAST_SENSORS + WEATHER_SENSORS
if dict_nested_value(coordinator.data["lib"], description.keys) is not None if dict_nested_value(coordinator.data["lib"], description.keys) is not None
@ -381,7 +382,6 @@ async def async_setup_entry(
class AemetSensor(AemetEntity, SensorEntity): class AemetSensor(AemetEntity, SensorEntity):
"""Implementation of an AEMET OpenData sensor.""" """Implementation of an AEMET OpenData sensor."""
_attr_attribution = ATTRIBUTION
entity_description: AemetSensorEntityDescription entity_description: AemetSensorEntityDescription
def __init__( def __init__(
@ -389,13 +389,12 @@ class AemetSensor(AemetEntity, SensorEntity):
name: str, name: str,
coordinator: WeatherUpdateCoordinator, coordinator: WeatherUpdateCoordinator,
description: AemetSensorEntityDescription, description: AemetSensorEntityDescription,
config_entry: ConfigEntry, unique_id: str,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator) super().__init__(coordinator, name, unique_id)
self.entity_description = description self.entity_description = description
self._attr_name = f"{name} {description.name}" self._attr_unique_id = f"{unique_id}-{description.key}"
self._attr_unique_id = f"{config_entry.unique_id}-{description.key}"
@property @property
def native_value(self): def native_value(self):

View File

@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AemetConfigEntry from . import AemetConfigEntry
from .const import ATTRIBUTION, CONDITIONS_MAP from .const import CONDITIONS_MAP
from .coordinator import WeatherUpdateCoordinator from .coordinator import WeatherUpdateCoordinator
from .entity import AemetEntity from .entity import AemetEntity
@ -43,10 +43,10 @@ async def async_setup_entry(
name = domain_data.name name = domain_data.name
weather_coordinator = domain_data.coordinator weather_coordinator = domain_data.coordinator
async_add_entities( unique_id = config_entry.unique_id
[AemetWeather(name, config_entry.unique_id, weather_coordinator)], assert unique_id is not None
False,
) async_add_entities([AemetWeather(name, unique_id, weather_coordinator)])
class AemetWeather( class AemetWeather(
@ -55,7 +55,6 @@ class AemetWeather(
): ):
"""Implementation of an AEMET OpenData weather.""" """Implementation of an AEMET OpenData weather."""
_attr_attribution = ATTRIBUTION
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
_attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_pressure_unit = UnitOfPressure.HPA
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_temperature_unit = UnitOfTemperature.CELSIUS
@ -63,16 +62,16 @@ class AemetWeather(
_attr_supported_features = ( _attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
) )
_attr_name = None
def __init__( def __init__(
self, self,
name, name: str,
unique_id, unique_id: str,
coordinator: WeatherUpdateCoordinator, coordinator: WeatherUpdateCoordinator,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator) super().__init__(coordinator, name, unique_id)
self._attr_name = name
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
@property @property

View File

@ -59,7 +59,7 @@ async def async_setup_entry(
platform = async_get_current_platform() platform = async_get_current_platform()
for service, method in CAMERA_SERVICES.items(): for service, method in CAMERA_SERVICES.items():
platform.async_register_entity_service(service, {}, method) platform.async_register_entity_service(service, None, method)
class AgentCamera(MjpegCamera): class AgentCamera(MjpegCamera):

View File

@ -9,10 +9,7 @@ from typing import Final, final
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers import config_validation as cv
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
)
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.helpers.typing import ConfigType, StateType
@ -21,6 +18,11 @@ from .const import DOMAIN
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL: Final = timedelta(seconds=30)
ATTR_AQI: Final = "air_quality_index" ATTR_AQI: Final = "air_quality_index"
ATTR_CO2: Final = "carbon_dioxide" ATTR_CO2: Final = "carbon_dioxide"
ATTR_CO: Final = "carbon_monoxide" ATTR_CO: Final = "carbon_monoxide"
@ -33,10 +35,6 @@ ATTR_PM_10: Final = "particulate_matter_10"
ATTR_PM_2_5: Final = "particulate_matter_2_5" ATTR_PM_2_5: Final = "particulate_matter_2_5"
ATTR_SO2: Final = "sulphur_dioxide" ATTR_SO2: Final = "sulphur_dioxide"
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
SCAN_INTERVAL: Final = timedelta(seconds=30)
PROP_TO_ATTR: Final[dict[str, str]] = { PROP_TO_ATTR: Final[dict[str, str]] = {
"air_quality_index": ATTR_AQI, "air_quality_index": ATTR_AQI,
"carbon_dioxide": ATTR_CO2, "carbon_dioxide": ATTR_CO2,

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from airgradient import AirGradientClient from airgradient import AirGradientClient, get_model_name
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform from homeassistant.const import CONF_HOST, Platform
@ -15,7 +15,14 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator
PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
@dataclass @dataclass
@ -29,7 +36,7 @@ class AirGradientData:
type AirGradientConfigEntry = ConfigEntry[AirGradientData] type AirGradientConfigEntry = ConfigEntry[AirGradientData]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool:
"""Set up Airgradient from a config entry.""" """Set up Airgradient from a config entry."""
client = AirGradientClient( client = AirGradientClient(
@ -47,7 +54,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
config_entry_id=entry.entry_id, config_entry_id=entry.entry_id,
identifiers={(DOMAIN, measurement_coordinator.serial_number)}, identifiers={(DOMAIN, measurement_coordinator.serial_number)},
manufacturer="AirGradient", manufacturer="AirGradient",
model=measurement_coordinator.data.model, model=get_model_name(measurement_coordinator.data.model),
model_id=measurement_coordinator.data.model,
serial_number=measurement_coordinator.data.serial_number, serial_number=measurement_coordinator.data.serial_number,
sw_version=measurement_coordinator.data.firmware_version, sw_version=measurement_coordinator.data.firmware_version,
) )
@ -62,6 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(
hass: HomeAssistant, entry: AirGradientConfigEntry
) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,104 @@
"""Support for AirGradient buttons."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from airgradient import AirGradientClient, ConfigurationControl
from homeassistant.components.button import (
DOMAIN as BUTTON_DOMAIN,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import DOMAIN, AirGradientConfigEntry
from .coordinator import AirGradientConfigCoordinator
from .entity import AirGradientEntity
@dataclass(frozen=True, kw_only=True)
class AirGradientButtonEntityDescription(ButtonEntityDescription):
"""Describes AirGradient button entity."""
press_fn: Callable[[AirGradientClient], Awaitable[None]]
CO2_CALIBRATION = AirGradientButtonEntityDescription(
key="co2_calibration",
translation_key="co2_calibration",
entity_category=EntityCategory.CONFIG,
press_fn=lambda client: client.request_co2_calibration(),
)
LED_BAR_TEST = AirGradientButtonEntityDescription(
key="led_bar_test",
translation_key="led_bar_test",
entity_category=EntityCategory.CONFIG,
press_fn=lambda client: client.request_led_bar_test(),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirGradientConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up AirGradient button entities based on a config entry."""
model = entry.runtime_data.measurement.data.model
coordinator = entry.runtime_data.config
added_entities = False
@callback
def _check_entities() -> None:
nonlocal added_entities
if (
coordinator.data.configuration_control is ConfigurationControl.LOCAL
and not added_entities
):
entities = [AirGradientButton(coordinator, CO2_CALIBRATION)]
if "L" in model:
entities.append(AirGradientButton(coordinator, LED_BAR_TEST))
async_add_entities(entities)
added_entities = True
elif (
coordinator.data.configuration_control is not ConfigurationControl.LOCAL
and added_entities
):
entity_registry = er.async_get(hass)
for entity_description in (CO2_CALIBRATION, LED_BAR_TEST):
unique_id = f"{coordinator.serial_number}-{entity_description.key}"
if entity_id := entity_registry.async_get_entity_id(
BUTTON_DOMAIN, DOMAIN, unique_id
):
entity_registry.async_remove(entity_id)
added_entities = False
coordinator.async_add_listener(_check_entities)
_check_entities()
class AirGradientButton(AirGradientEntity, ButtonEntity):
"""Defines an AirGradient button."""
entity_description: AirGradientButtonEntityDescription
coordinator: AirGradientConfigCoordinator
def __init__(
self,
coordinator: AirGradientConfigCoordinator,
description: AirGradientButtonEntityDescription,
) -> None:
"""Initialize airgradient button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.coordinator.client)

View File

@ -2,9 +2,13 @@
from typing import Any from typing import Any
from airgradient import AirGradientClient, AirGradientError, ConfigurationControl from airgradient import (
AirGradientClient,
AirGradientError,
AirGradientParseError,
ConfigurationControl,
)
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from mashumaro import MissingField
import voluptuous as vol import voluptuous as vol
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
@ -83,10 +87,10 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
self.client = AirGradientClient(user_input[CONF_HOST], session=session) self.client = AirGradientClient(user_input[CONF_HOST], session=session)
try: try:
current_measures = await self.client.get_current_measures() current_measures = await self.client.get_current_measures()
except AirGradientParseError:
return self.async_abort(reason="invalid_version")
except AirGradientError: except AirGradientError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except MissingField:
return self.async_abort(reason="invalid_version")
else: else:
await self.async_set_unique_id(current_measures.serial_number) await self.async_set_unique_id(current_measures.serial_number)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()

View File

@ -2,6 +2,14 @@
import logging import logging
from airgradient import PmStandard
DOMAIN = "airgradient" DOMAIN = "airgradient"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
PM_STANDARD = {
PmStandard.UGM3: "ugm3",
PmStandard.USAQI: "us_aqi",
}
PM_STANDARD_REVERSE = {v: k for k, v in PM_STANDARD.items()}

View File

@ -19,7 +19,6 @@ if TYPE_CHECKING:
class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]): class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""Class to manage fetching AirGradient data.""" """Class to manage fetching AirGradient data."""
_update_interval: timedelta
config_entry: AirGradientConfigEntry config_entry: AirGradientConfigEntry
def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None: def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None:
@ -28,7 +27,7 @@ class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
hass, hass,
logger=LOGGER, logger=LOGGER,
name=f"AirGradient {client.host}", name=f"AirGradient {client.host}",
update_interval=self._update_interval, update_interval=timedelta(minutes=1),
) )
self.client = client self.client = client
assert self.config_entry.unique_id assert self.config_entry.unique_id
@ -47,8 +46,6 @@ class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]): class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]):
"""Class to manage fetching AirGradient data.""" """Class to manage fetching AirGradient data."""
_update_interval = timedelta(minutes=1)
async def _update_data(self) -> Measures: async def _update_data(self) -> Measures:
return await self.client.get_current_measures() return await self.client.get_current_measures()
@ -56,7 +53,5 @@ class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]):
class AirGradientConfigCoordinator(AirGradientCoordinator[Config]): class AirGradientConfigCoordinator(AirGradientCoordinator[Config]):
"""Class to manage fetching AirGradient data.""" """Class to manage fetching AirGradient data."""
_update_interval = timedelta(minutes=5)
async def _update_data(self) -> Config: async def _update_data(self) -> Config:
return await self.client.get_config() return await self.client.get_config()

View File

@ -1,5 +1,41 @@
{ {
"entity": { "entity": {
"button": {
"co2_calibration": {
"default": "mdi:molecule-co2"
},
"led_bar_test": {
"default": "mdi:lightbulb-on-outline"
}
},
"number": {
"led_bar_brightness": {
"default": "mdi:brightness-percent"
},
"display_brightness": {
"default": "mdi:brightness-percent"
}
},
"select": {
"configuration_control": {
"default": "mdi:cloud-cog"
},
"display_temperature_unit": {
"default": "mdi:thermometer-lines"
},
"led_bar_mode": {
"default": "mdi:led-strip"
},
"nox_index_learning_time_offset": {
"default": "mdi:clock-outline"
},
"voc_index_learning_time_offset": {
"default": "mdi:clock-outline"
},
"co2_automatic_baseline_calibration": {
"default": "mdi:molecule-co2"
}
},
"sensor": { "sensor": {
"total_volatile_organic_component_index": { "total_volatile_organic_component_index": {
"default": "mdi:molecule" "default": "mdi:molecule"
@ -9,6 +45,32 @@
}, },
"pm003_count": { "pm003_count": {
"default": "mdi:blur" "default": "mdi:blur"
},
"led_bar_brightness": {
"default": "mdi:brightness-percent"
},
"display_brightness": {
"default": "mdi:brightness-percent"
},
"display_temperature_unit": {
"default": "mdi:thermometer-lines"
},
"led_bar_mode": {
"default": "mdi:led-strip"
},
"nox_index_learning_time_offset": {
"default": "mdi:clock-outline"
},
"voc_index_learning_time_offset": {
"default": "mdi:clock-outline"
},
"co2_automatic_baseline_calibration": {
"default": "mdi:molecule-co2"
}
},
"switch": {
"post_data_to_airgradient": {
"default": "mdi:cogs"
} }
} }
} }

View File

@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/airgradient", "documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["airgradient==0.6.0"], "requirements": ["airgradient==0.8.0"],
"zeroconf": ["_airgradient._tcp.local."] "zeroconf": ["_airgradient._tcp.local."]
} }

View File

@ -0,0 +1,127 @@
"""Support for AirGradient number entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from airgradient import AirGradientClient, Config
from airgradient.models import ConfigurationControl
from homeassistant.components.number import (
DOMAIN as NUMBER_DOMAIN,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
from .coordinator import AirGradientConfigCoordinator
from .entity import AirGradientEntity
@dataclass(frozen=True, kw_only=True)
class AirGradientNumberEntityDescription(NumberEntityDescription):
"""Describes AirGradient number entity."""
value_fn: Callable[[Config], int]
set_value_fn: Callable[[AirGradientClient, int], Awaitable[None]]
DISPLAY_BRIGHTNESS = AirGradientNumberEntityDescription(
key="display_brightness",
translation_key="display_brightness",
entity_category=EntityCategory.CONFIG,
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda config: config.display_brightness,
set_value_fn=lambda client, value: client.set_display_brightness(value),
)
LED_BAR_BRIGHTNESS = AirGradientNumberEntityDescription(
key="led_bar_brightness",
translation_key="led_bar_brightness",
entity_category=EntityCategory.CONFIG,
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda config: config.led_bar_brightness,
set_value_fn=lambda client, value: client.set_led_bar_brightness(value),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirGradientConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up AirGradient number entities based on a config entry."""
model = entry.runtime_data.measurement.data.model
coordinator = entry.runtime_data.config
added_entities = False
@callback
def _async_check_entities() -> None:
nonlocal added_entities
if (
coordinator.data.configuration_control is ConfigurationControl.LOCAL
and not added_entities
):
entities = []
if "I" in model:
entities.append(AirGradientNumber(coordinator, DISPLAY_BRIGHTNESS))
if "L" in model:
entities.append(AirGradientNumber(coordinator, LED_BAR_BRIGHTNESS))
async_add_entities(entities)
added_entities = True
elif (
coordinator.data.configuration_control is not ConfigurationControl.LOCAL
and added_entities
):
entity_registry = er.async_get(hass)
for entity_description in (DISPLAY_BRIGHTNESS, LED_BAR_BRIGHTNESS):
unique_id = f"{coordinator.serial_number}-{entity_description.key}"
if entity_id := entity_registry.async_get_entity_id(
NUMBER_DOMAIN, DOMAIN, unique_id
):
entity_registry.async_remove(entity_id)
added_entities = False
coordinator.async_add_listener(_async_check_entities)
_async_check_entities()
class AirGradientNumber(AirGradientEntity, NumberEntity):
"""Defines an AirGradient number entity."""
entity_description: AirGradientNumberEntityDescription
coordinator: AirGradientConfigCoordinator
def __init__(
self,
coordinator: AirGradientConfigCoordinator,
description: AirGradientNumberEntityDescription,
) -> None:
"""Initialize AirGradient number."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
@property
def native_value(self) -> int | None:
"""Return the state of the number."""
return self.entity_description.value_fn(self.coordinator.data)
async def async_set_native_value(self, value: float) -> None:
"""Set the selected value."""
await self.entity_description.set_value_fn(self.coordinator.client, int(value))
await self.coordinator.async_request_refresh()

View File

@ -4,30 +4,23 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from airgradient import AirGradientClient, Config from airgradient import AirGradientClient, Config
from airgradient.models import ( from airgradient.models import ConfigurationControl, LedBarMode, TemperatureUnit
ConfigurationControl,
LedBarMode,
PmStandard,
TemperatureUnit,
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SelectEntity,
SelectEntityDescription,
)
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry from . import AirGradientConfigEntry
from .const import DOMAIN from .const import DOMAIN, PM_STANDARD, PM_STANDARD_REVERSE
from .coordinator import AirGradientConfigCoordinator from .coordinator import AirGradientConfigCoordinator
from .entity import AirGradientEntity from .entity import AirGradientEntity
PM_STANDARD = {
PmStandard.UGM3: "ugm3",
PmStandard.USAQI: "us_aqi",
}
PM_STANDARD_REVERSE = {v: k for k, v in PM_STANDARD.items()}
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class AirGradientSelectEntityDescription(SelectEntityDescription): class AirGradientSelectEntityDescription(SelectEntityDescription):
@ -35,8 +28,6 @@ class AirGradientSelectEntityDescription(SelectEntityDescription):
value_fn: Callable[[Config], str | None] value_fn: Callable[[Config], str | None]
set_value_fn: Callable[[AirGradientClient, str], Awaitable[None]] set_value_fn: Callable[[AirGradientClient, str], Awaitable[None]]
requires_display: bool = False
requires_led_bar: bool = False
CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription( CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription(
@ -54,7 +45,7 @@ CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription(
), ),
) )
PROTECTED_SELECT_TYPES: tuple[AirGradientSelectEntityDescription, ...] = ( DISPLAY_SELECT_TYPES: tuple[AirGradientSelectEntityDescription, ...] = (
AirGradientSelectEntityDescription( AirGradientSelectEntityDescription(
key="display_temperature_unit", key="display_temperature_unit",
translation_key="display_temperature_unit", translation_key="display_temperature_unit",
@ -64,7 +55,6 @@ PROTECTED_SELECT_TYPES: tuple[AirGradientSelectEntityDescription, ...] = (
set_value_fn=lambda client, value: client.set_temperature_unit( set_value_fn=lambda client, value: client.set_temperature_unit(
TemperatureUnit(value) TemperatureUnit(value)
), ),
requires_display=True,
), ),
AirGradientSelectEntityDescription( AirGradientSelectEntityDescription(
key="display_pm_standard", key="display_pm_standard",
@ -75,8 +65,10 @@ PROTECTED_SELECT_TYPES: tuple[AirGradientSelectEntityDescription, ...] = (
set_value_fn=lambda client, value: client.set_pm_standard( set_value_fn=lambda client, value: client.set_pm_standard(
PM_STANDARD_REVERSE[value] PM_STANDARD_REVERSE[value]
), ),
requires_display=True,
), ),
)
LED_BAR_ENTITIES: tuple[AirGradientSelectEntityDescription, ...] = (
AirGradientSelectEntityDescription( AirGradientSelectEntityDescription(
key="led_bar_mode", key="led_bar_mode",
translation_key="led_bar_mode", translation_key="led_bar_mode",
@ -84,7 +76,63 @@ PROTECTED_SELECT_TYPES: tuple[AirGradientSelectEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.led_bar_mode, value_fn=lambda config: config.led_bar_mode,
set_value_fn=lambda client, value: client.set_led_bar_mode(LedBarMode(value)), set_value_fn=lambda client, value: client.set_led_bar_mode(LedBarMode(value)),
requires_led_bar=True, ),
)
LEARNING_TIME_OFFSET_OPTIONS = [
"12",
"60",
"120",
"360",
"720",
]
ABC_DAYS = [
"1",
"8",
"30",
"90",
"180",
"0",
]
def _get_value(value: int, values: list[str]) -> str | None:
str_value = str(value)
return str_value if str_value in values else None
CONTROL_ENTITIES: tuple[AirGradientSelectEntityDescription, ...] = (
AirGradientSelectEntityDescription(
key="nox_index_learning_time_offset",
translation_key="nox_index_learning_time_offset",
options=LEARNING_TIME_OFFSET_OPTIONS,
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: _get_value(
config.nox_learning_offset, LEARNING_TIME_OFFSET_OPTIONS
),
set_value_fn=lambda client, value: client.set_nox_learning_offset(int(value)),
),
AirGradientSelectEntityDescription(
key="voc_index_learning_time_offset",
translation_key="voc_index_learning_time_offset",
options=LEARNING_TIME_OFFSET_OPTIONS,
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: _get_value(
config.tvoc_learning_offset, LEARNING_TIME_OFFSET_OPTIONS
),
set_value_fn=lambda client, value: client.set_tvoc_learning_offset(int(value)),
),
AirGradientSelectEntityDescription(
key="co2_automatic_baseline_calibration",
translation_key="co2_automatic_baseline_calibration",
options=ABC_DAYS,
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: _get_value(
config.co2_automatic_baseline_calibration_days, ABC_DAYS
),
set_value_fn=lambda client,
value: client.set_co2_automatic_baseline_calibration(int(value)),
), ),
) )
@ -96,22 +144,57 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up AirGradient select entities based on a config entry.""" """Set up AirGradient select entities based on a config entry."""
config_coordinator = entry.runtime_data.config coordinator = entry.runtime_data.config
measurement_coordinator = entry.runtime_data.measurement measurement_coordinator = entry.runtime_data.measurement
entities = [AirGradientSelect(config_coordinator, CONFIG_CONTROL_ENTITY)] async_add_entities([AirGradientSelect(coordinator, CONFIG_CONTROL_ENTITY)])
model = measurement_coordinator.data.model
added_entities = False
@callback
def _async_check_entities() -> None:
nonlocal added_entities
entities.extend(
AirGradientProtectedSelect(config_coordinator, description)
for description in PROTECTED_SELECT_TYPES
if ( if (
description.requires_display coordinator.data.configuration_control is ConfigurationControl.LOCAL
and measurement_coordinator.data.model.startswith("I") and not added_entities
) ):
or (description.requires_led_bar and "L" in measurement_coordinator.data.model) entities: list[AirGradientSelect] = [
) AirGradientSelect(coordinator, description)
for description in CONTROL_ENTITIES
]
if "I" in model:
entities.extend(
AirGradientSelect(coordinator, description)
for description in DISPLAY_SELECT_TYPES
)
if "L" in model:
entities.extend(
AirGradientSelect(coordinator, description)
for description in LED_BAR_ENTITIES
)
async_add_entities(entities) async_add_entities(entities)
added_entities = True
elif (
coordinator.data.configuration_control is not ConfigurationControl.LOCAL
and added_entities
):
entity_registry = er.async_get(hass)
for entity_description in (
DISPLAY_SELECT_TYPES + LED_BAR_ENTITIES + CONTROL_ENTITIES
):
unique_id = f"{coordinator.serial_number}-{entity_description.key}"
if entity_id := entity_registry.async_get_entity_id(
SELECT_DOMAIN, DOMAIN, unique_id
):
entity_registry.async_remove(entity_id)
added_entities = False
coordinator.async_add_listener(_async_check_entities)
_async_check_entities()
class AirGradientSelect(AirGradientEntity, SelectEntity): class AirGradientSelect(AirGradientEntity, SelectEntity):
@ -139,19 +222,3 @@ class AirGradientSelect(AirGradientEntity, SelectEntity):
"""Change the selected option.""" """Change the selected option."""
await self.entity_description.set_value_fn(self.coordinator.client, option) await self.entity_description.set_value_fn(self.coordinator.client, option)
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
class AirGradientProtectedSelect(AirGradientSelect):
"""Defines a protected AirGradient select entity."""
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
if (
self.coordinator.data.configuration_control
is not ConfigurationControl.LOCAL
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_local_configuration",
)
await super().async_select_option(option)

View File

@ -3,7 +3,13 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from airgradient.models import Measures from airgradient import Config
from airgradient.models import (
ConfigurationControl,
LedBarMode,
Measures,
TemperatureUnit,
)
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -18,60 +24,69 @@ from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory, EntityCategory,
UnitOfTemperature, UnitOfTemperature,
UnitOfTime,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from . import AirGradientConfigEntry from . import AirGradientConfigEntry
from .coordinator import AirGradientMeasurementCoordinator from .const import PM_STANDARD, PM_STANDARD_REVERSE
from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator
from .entity import AirGradientEntity from .entity import AirGradientEntity
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class AirGradientSensorEntityDescription(SensorEntityDescription): class AirGradientMeasurementSensorEntityDescription(SensorEntityDescription):
"""Describes AirGradient sensor entity.""" """Describes AirGradient measurement sensor entity."""
value_fn: Callable[[Measures], StateType] value_fn: Callable[[Measures], StateType]
SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = ( @dataclass(frozen=True, kw_only=True)
AirGradientSensorEntityDescription( class AirGradientConfigSensorEntityDescription(SensorEntityDescription):
"""Describes AirGradient config sensor entity."""
value_fn: Callable[[Config], StateType]
MEASUREMENT_SENSOR_TYPES: tuple[AirGradientMeasurementSensorEntityDescription, ...] = (
AirGradientMeasurementSensorEntityDescription(
key="pm01", key="pm01",
device_class=SensorDeviceClass.PM1, device_class=SensorDeviceClass.PM1,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm01, value_fn=lambda status: status.pm01,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="pm02", key="pm02",
device_class=SensorDeviceClass.PM25, device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm02, value_fn=lambda status: status.pm02,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="pm10", key="pm10",
device_class=SensorDeviceClass.PM10, device_class=SensorDeviceClass.PM10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm10, value_fn=lambda status: status.pm10,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="temperature", key="temperature",
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.ambient_temperature, value_fn=lambda status: status.ambient_temperature,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="humidity", key="humidity",
device_class=SensorDeviceClass.HUMIDITY, device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.relative_humidity, value_fn=lambda status: status.relative_humidity,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="signal_strength", key="signal_strength",
device_class=SensorDeviceClass.SIGNAL_STRENGTH, device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
@ -80,33 +95,33 @@ SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=lambda status: status.signal_strength, value_fn=lambda status: status.signal_strength,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="tvoc", key="tvoc",
translation_key="total_volatile_organic_component_index", translation_key="total_volatile_organic_component_index",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.total_volatile_organic_component_index, value_fn=lambda status: status.total_volatile_organic_component_index,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="nitrogen_index", key="nitrogen_index",
translation_key="nitrogen_index", translation_key="nitrogen_index",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.nitrogen_index, value_fn=lambda status: status.nitrogen_index,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="co2", key="co2",
device_class=SensorDeviceClass.CO2, device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.rco2, value_fn=lambda status: status.rco2,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="pm003", key="pm003",
translation_key="pm003_count", translation_key="pm003_count",
native_unit_of_measurement="particles/dL", native_unit_of_measurement="particles/dL",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm003_count, value_fn=lambda status: status.pm003_count,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="nox_raw", key="nox_raw",
translation_key="raw_nitrogen", translation_key="raw_nitrogen",
native_unit_of_measurement="ticks", native_unit_of_measurement="ticks",
@ -114,7 +129,7 @@ SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=lambda status: status.raw_nitrogen, value_fn=lambda status: status.raw_nitrogen,
), ),
AirGradientSensorEntityDescription( AirGradientMeasurementSensorEntityDescription(
key="tvoc_raw", key="tvoc_raw",
translation_key="raw_total_volatile_organic_component", translation_key="raw_total_volatile_organic_component",
native_unit_of_measurement="ticks", native_unit_of_measurement="ticks",
@ -124,6 +139,77 @@ SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = (
), ),
) )
CONFIG_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...] = (
AirGradientConfigSensorEntityDescription(
key="co2_automatic_baseline_calibration_days",
translation_key="co2_automatic_baseline_calibration_days",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda config: config.co2_automatic_baseline_calibration_days,
),
AirGradientConfigSensorEntityDescription(
key="nox_learning_offset",
translation_key="nox_learning_offset",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda config: config.nox_learning_offset,
),
AirGradientConfigSensorEntityDescription(
key="tvoc_learning_offset",
translation_key="tvoc_learning_offset",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda config: config.tvoc_learning_offset,
),
)
CONFIG_LED_BAR_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...] = (
AirGradientConfigSensorEntityDescription(
key="led_bar_mode",
translation_key="led_bar_mode",
device_class=SensorDeviceClass.ENUM,
options=[x.value for x in LedBarMode],
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda config: config.led_bar_mode,
),
AirGradientConfigSensorEntityDescription(
key="led_bar_brightness",
translation_key="led_bar_brightness",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda config: config.led_bar_brightness,
),
)
CONFIG_DISPLAY_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...] = (
AirGradientConfigSensorEntityDescription(
key="display_temperature_unit",
translation_key="display_temperature_unit",
device_class=SensorDeviceClass.ENUM,
options=[x.value for x in TemperatureUnit],
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda config: config.temperature_unit,
),
AirGradientConfigSensorEntityDescription(
key="display_pm_standard",
translation_key="display_pm_standard",
device_class=SensorDeviceClass.ENUM,
options=list(PM_STANDARD_REVERSE),
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda config: PM_STANDARD.get(config.pm_standard),
),
AirGradientConfigSensorEntityDescription(
key="display_brightness",
translation_key="display_brightness",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda config: config.display_brightness,
),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -134,7 +220,9 @@ async def async_setup_entry(
coordinator = entry.runtime_data.measurement coordinator = entry.runtime_data.measurement
listener: Callable[[], None] | None = None listener: Callable[[], None] | None = None
not_setup: set[AirGradientSensorEntityDescription] = set(SENSOR_TYPES) not_setup: set[AirGradientMeasurementSensorEntityDescription] = set(
MEASUREMENT_SENSOR_TYPES
)
@callback @callback
def add_entities() -> None: def add_entities() -> None:
@ -147,7 +235,7 @@ async def async_setup_entry(
if description.value_fn(coordinator.data) is None: if description.value_fn(coordinator.data) is None:
not_setup.add(description) not_setup.add(description)
else: else:
sensors.append(AirGradientSensor(coordinator, description)) sensors.append(AirGradientMeasurementSensor(coordinator, description))
if sensors: if sensors:
async_add_entities(sensors) async_add_entities(sensors)
@ -159,17 +247,33 @@ async def async_setup_entry(
add_entities() add_entities()
entities = [
AirGradientConfigSensor(entry.runtime_data.config, description)
for description in CONFIG_SENSOR_TYPES
]
if "L" in coordinator.data.model:
entities.extend(
AirGradientConfigSensor(entry.runtime_data.config, description)
for description in CONFIG_LED_BAR_SENSOR_TYPES
)
if "I" in coordinator.data.model:
entities.extend(
AirGradientConfigSensor(entry.runtime_data.config, description)
for description in CONFIG_DISPLAY_SENSOR_TYPES
)
async_add_entities(entities)
class AirGradientSensor(AirGradientEntity, SensorEntity):
class AirGradientMeasurementSensor(AirGradientEntity, SensorEntity):
"""Defines an AirGradient sensor.""" """Defines an AirGradient sensor."""
entity_description: AirGradientSensorEntityDescription entity_description: AirGradientMeasurementSensorEntityDescription
coordinator: AirGradientMeasurementCoordinator coordinator: AirGradientMeasurementCoordinator
def __init__( def __init__(
self, self,
coordinator: AirGradientMeasurementCoordinator, coordinator: AirGradientMeasurementCoordinator,
description: AirGradientSensorEntityDescription, description: AirGradientMeasurementSensorEntityDescription,
) -> None: ) -> None:
"""Initialize airgradient sensor.""" """Initialize airgradient sensor."""
super().__init__(coordinator) super().__init__(coordinator)
@ -180,3 +284,28 @@ class AirGradientSensor(AirGradientEntity, SensorEntity):
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data) return self.entity_description.value_fn(self.coordinator.data)
class AirGradientConfigSensor(AirGradientEntity, SensorEntity):
"""Defines an AirGradient sensor."""
entity_description: AirGradientConfigSensorEntityDescription
coordinator: AirGradientConfigCoordinator
def __init__(
self,
coordinator: AirGradientConfigCoordinator,
description: AirGradientConfigSensorEntityDescription,
) -> None:
"""Initialize airgradient sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
self._attr_entity_registry_enabled_default = (
coordinator.data.configuration_control is not ConfigurationControl.LOCAL
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -16,6 +16,7 @@
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1." "invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1."
}, },
"error": { "error": {
@ -24,6 +25,22 @@
} }
}, },
"entity": { "entity": {
"button": {
"co2_calibration": {
"name": "Calibrate CO2 sensor"
},
"led_bar_test": {
"name": "Test LED bar"
}
},
"number": {
"led_bar_brightness": {
"name": "LED bar brightness"
},
"display_brightness": {
"name": "Display brightness"
}
},
"select": { "select": {
"configuration_control": { "configuration_control": {
"name": "Configuration source", "name": "Configuration source",
@ -53,6 +70,37 @@
"co2": "Carbon dioxide", "co2": "Carbon dioxide",
"pm": "Particulate matter" "pm": "Particulate matter"
} }
},
"nox_index_learning_time_offset": {
"name": "NOx index learning offset",
"state": {
"12": "12 hours",
"60": "60 hours",
"120": "120 hours",
"360": "360 hours",
"720": "720 hours"
}
},
"voc_index_learning_time_offset": {
"name": "VOC index learning offset",
"state": {
"12": "[%key:component::airgradient::entity::select::nox_index_learning_time_offset::state::12%]",
"60": "[%key:component::airgradient::entity::select::nox_index_learning_time_offset::state::60%]",
"120": "[%key:component::airgradient::entity::select::nox_index_learning_time_offset::state::120%]",
"360": "[%key:component::airgradient::entity::select::nox_index_learning_time_offset::state::360%]",
"720": "[%key:component::airgradient::entity::select::nox_index_learning_time_offset::state::720%]"
}
},
"co2_automatic_baseline_calibration": {
"name": "CO2 automatic baseline duration",
"state": {
"1": "1 day",
"8": "8 days",
"30": "30 days",
"90": "90 days",
"180": "180 days",
"0": "[%key:common::state::off%]"
}
} }
}, },
"sensor": { "sensor": {
@ -70,12 +118,49 @@
}, },
"raw_nitrogen": { "raw_nitrogen": {
"name": "Raw NOx" "name": "Raw NOx"
},
"display_pm_standard": {
"name": "[%key:component::airgradient::entity::select::display_pm_standard::name%]",
"state": {
"ugm3": "[%key:component::airgradient::entity::select::display_pm_standard::state::ugm3%]",
"us_aqi": "[%key:component::airgradient::entity::select::display_pm_standard::state::us_aqi%]"
}
},
"co2_automatic_baseline_calibration_days": {
"name": "Carbon dioxide automatic baseline calibration"
},
"nox_learning_offset": {
"name": "[%key:component::airgradient::entity::select::nox_index_learning_time_offset::name%]"
},
"tvoc_learning_offset": {
"name": "[%key:component::airgradient::entity::select::voc_index_learning_time_offset::name%]"
},
"led_bar_mode": {
"name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]",
"state": {
"off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]",
"co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]",
"pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]"
}
},
"led_bar_brightness": {
"name": "[%key:component::airgradient::entity::number::led_bar_brightness::name%]"
},
"display_temperature_unit": {
"name": "[%key:component::airgradient::entity::select::display_temperature_unit::name%]",
"state": {
"c": "[%key:component::airgradient::entity::select::display_temperature_unit::state::c%]",
"f": "[%key:component::airgradient::entity::select::display_temperature_unit::state::f%]"
}
},
"display_brightness": {
"name": "[%key:component::airgradient::entity::number::display_brightness::name%]"
}
},
"switch": {
"post_data_to_airgradient": {
"name": "Post data to Airgradient"
} }
}
},
"exceptions": {
"no_local_configuration": {
"message": "Device should be configured with local configuration to be able to change settings."
} }
} }
} }

View File

@ -0,0 +1,110 @@
"""Support for AirGradient switch entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from airgradient import AirGradientClient, Config
from airgradient.models import ConfigurationControl
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
from .coordinator import AirGradientConfigCoordinator
from .entity import AirGradientEntity
@dataclass(frozen=True, kw_only=True)
class AirGradientSwitchEntityDescription(SwitchEntityDescription):
"""Describes AirGradient switch entity."""
value_fn: Callable[[Config], bool]
set_value_fn: Callable[[AirGradientClient, bool], Awaitable[None]]
POST_DATA_TO_AIRGRADIENT = AirGradientSwitchEntityDescription(
key="post_data_to_airgradient",
translation_key="post_data_to_airgradient",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.post_data_to_airgradient,
set_value_fn=lambda client, value: client.enable_sharing_data(enable=value),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirGradientConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up AirGradient switch entities based on a config entry."""
coordinator = entry.runtime_data.config
added_entities = False
@callback
def _async_check_entities() -> None:
nonlocal added_entities
if (
coordinator.data.configuration_control is ConfigurationControl.LOCAL
and not added_entities
):
async_add_entities(
[AirGradientSwitch(coordinator, POST_DATA_TO_AIRGRADIENT)]
)
added_entities = True
elif (
coordinator.data.configuration_control is not ConfigurationControl.LOCAL
and added_entities
):
entity_registry = er.async_get(hass)
unique_id = f"{coordinator.serial_number}-{POST_DATA_TO_AIRGRADIENT.key}"
if entity_id := entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, unique_id
):
entity_registry.async_remove(entity_id)
added_entities = False
coordinator.async_add_listener(_async_check_entities)
_async_check_entities()
class AirGradientSwitch(AirGradientEntity, SwitchEntity):
"""Defines an AirGradient switch entity."""
entity_description: AirGradientSwitchEntityDescription
coordinator: AirGradientConfigCoordinator
def __init__(
self,
coordinator: AirGradientConfigCoordinator,
description: AirGradientSwitchEntityDescription,
) -> None:
"""Initialize AirGradient switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
@property
def is_on(self) -> bool:
"""Return the state of the switch."""
return self.entity_description.value_fn(self.coordinator.data)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_value_fn(self.coordinator.client, True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_value_fn(self.coordinator.client, False)
await self.coordinator.async_request_refresh()

View File

@ -0,0 +1,55 @@
"""Airgradient Update platform."""
from datetime import timedelta
from functools import cached_property
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry, AirGradientMeasurementCoordinator
from .entity import AirGradientEntity
SCAN_INTERVAL = timedelta(hours=1)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirGradientConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Airgradient update platform."""
data = config_entry.runtime_data
async_add_entities([AirGradientUpdate(data.measurement)], True)
class AirGradientUpdate(AirGradientEntity, UpdateEntity):
"""Representation of Airgradient Update."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
coordinator: AirGradientMeasurementCoordinator
def __init__(self, coordinator: AirGradientMeasurementCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.serial_number}-update"
@cached_property
def should_poll(self) -> bool:
"""Return True because we need to poll the latest version."""
return True
@property
def installed_version(self) -> str:
"""Return the installed version of the entity."""
return self.coordinator.data.firmware_version
async def async_update(self) -> None:
"""Update the entity."""
self._attr_latest_version = (
await self.coordinator.client.get_latest_firmware_version(
self.coordinator.serial_number
)
)

View File

@ -156,7 +156,8 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") raise ValueError(f"Unsupported HVAC mode: {hvac_mode}")
if hvac_mode == HVACMode.OFF: if hvac_mode == HVACMode.OFF:
return await self.async_turn_off() await self.async_turn_off()
return
await self._airtouch.SetCoolingModeForAc( await self._airtouch.SetCoolingModeForAc(
self._ac_number, HA_STATE_TO_AT[hvac_mode] self._ac_number, HA_STATE_TO_AT[hvac_mode]
) )
@ -262,7 +263,8 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") raise ValueError(f"Unsupported HVAC mode: {hvac_mode}")
if hvac_mode == HVACMode.OFF: if hvac_mode == HVACMode.OFF:
return await self.async_turn_off() await self.async_turn_off()
return
if self.hvac_mode == HVACMode.OFF: if self.hvac_mode == HVACMode.OFF:
await self.async_turn_on() await self.async_turn_on()
self._unit = self._airtouch.GetGroups()[self._group_number] self._unit = self._airtouch.GetGroups()[self._group_number]

View File

@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN from .const import DOMAIN
PLATFORMS: list[Platform] = [Platform.CLIMATE] PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.COVER]
type Airtouch5ConfigEntry = ConfigEntry[Airtouch5SimpleClient] type Airtouch5ConfigEntry = ConfigEntry[Airtouch5SimpleClient]

View File

@ -121,6 +121,7 @@ class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity):
"""Base class for Airtouch5 Climate Entities.""" """Base class for Airtouch5 Climate Entities."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
_attr_target_temperature_step = 1 _attr_target_temperature_step = 1
_attr_name = None _attr_name = None
_enable_turn_on_off_backwards_compatibility = False _enable_turn_on_off_backwards_compatibility = False

View File

@ -0,0 +1,134 @@
"""Representation of the Damper for AirTouch 5 Devices."""
import logging
from typing import Any
from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient
from airtouch5py.packets.zone_control import (
ZoneControlZone,
ZoneSettingPower,
ZoneSettingValue,
)
from airtouch5py.packets.zone_name import ZoneName
from airtouch5py.packets.zone_status import ZoneStatusZone
from homeassistant.components.cover import (
ATTR_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Airtouch5ConfigEntry
from .const import DOMAIN
from .entity import Airtouch5Entity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: Airtouch5ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Airtouch 5 Cover entities."""
client = config_entry.runtime_data
# Each zone has a cover for its open percentage
async_add_entities(
Airtouch5ZoneOpenPercentage(
client, zone, client.latest_zone_status[zone.zone_number].has_sensor
)
for zone in client.zones
)
class Airtouch5ZoneOpenPercentage(CoverEntity, Airtouch5Entity):
"""How open the damper is in each zone."""
_attr_device_class = CoverDeviceClass.DAMPER
_attr_translation_key = "damper"
# Zones with temperature sensors shouldn't be manually controlled.
# We allow it but warn the user in the integration documentation.
_attr_supported_features = (
CoverEntityFeature.SET_POSITION
| CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
)
def __init__(
self, client: Airtouch5SimpleClient, zone_name: ZoneName, has_sensor: bool
) -> None:
"""Initialise the Cover Entity."""
super().__init__(client)
self._zone_name = zone_name
self._attr_unique_id = f"zone_{zone_name.zone_number}_open_percentage"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"zone_{zone_name.zone_number}")},
name=zone_name.zone_name,
manufacturer="Polyaire",
model="AirTouch 5",
)
@callback
def _async_update_attrs(self, data: dict[int, ZoneStatusZone]) -> None:
if self._zone_name.zone_number not in data:
return
status = data[self._zone_name.zone_number]
self._attr_current_cover_position = int(status.open_percentage * 100)
if status.open_percentage == 0:
self._attr_is_closed = True
else:
self._attr_is_closed = False
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Add data updated listener after this object has been initialized."""
await super().async_added_to_hass()
self._client.zone_status_callbacks.append(self._async_update_attrs)
self._async_update_attrs(self._client.latest_zone_status)
async def async_will_remove_from_hass(self) -> None:
"""Remove data updated listener after this object has been initialized."""
await super().async_will_remove_from_hass()
self._client.zone_status_callbacks.remove(self._async_update_attrs)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the damper."""
await self._set_cover_position(100)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close damper."""
await self._set_cover_position(0)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Update the damper to a specific position."""
if (position := kwargs.get(ATTR_POSITION)) is None:
_LOGGER.debug("Argument `position` is missing in set_cover_position")
return
await self._set_cover_position(position)
async def _set_cover_position(self, position_percent: float) -> None:
power: ZoneSettingPower
if position_percent == 0:
power = ZoneSettingPower.SET_TO_OFF
else:
power = ZoneSettingPower.SET_TO_ON
zcz = ZoneControlZone(
self._zone_name.zone_number,
ZoneSettingValue.SET_OPEN_PERCENTAGE,
power,
position_percent / 100.0,
)
packet = self._client.data_packet_factory.zone_control([zcz])
await self._client.send_packet(packet)

View File

@ -6,15 +6,12 @@ from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import DOMAIN
class Airtouch5Entity(Entity): class Airtouch5Entity(Entity):
"""Base class for Airtouch5 entities.""" """Base class for Airtouch5 entities."""
_attr_should_poll = False _attr_should_poll = False
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_translation_key = DOMAIN
def __init__(self, client: Airtouch5SimpleClient) -> None: def __init__(self, client: Airtouch5SimpleClient) -> None:
"""Initialise the Entity.""" """Initialise the Entity."""

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airtouch5", "documentation": "https://www.home-assistant.io/integrations/airtouch5",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["airtouch5py"], "loggers": ["airtouch5py"],
"requirements": ["airtouch5py==0.2.8"] "requirements": ["airtouch5py==0.2.10"]
} }

View File

@ -27,6 +27,11 @@
} }
} }
} }
},
"cover": {
"damper": {
"name": "[%key:component::cover::entity_component::damper::name%]"
}
} }
} }
} }

View File

@ -31,7 +31,6 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import ( from homeassistant.helpers import (
aiohttp_client, aiohttp_client,
config_validation as cv,
device_registry as dr, device_registry as dr,
entity_registry as er, entity_registry as er,
) )
@ -62,8 +61,6 @@ PLATFORMS = [Platform.SENSOR]
DEFAULT_ATTRIBUTION = "Data provided by AirVisual" DEFAULT_ATTRIBUTION = "Data provided by AirVisual"
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
@callback @callback
def async_get_cloud_api_update_interval( def async_get_cloud_api_update_interval(

View File

@ -82,33 +82,54 @@ async def async_setup_entry(
"""Add Airzone binary sensors from a config_entry.""" """Add Airzone binary sensors from a config_entry."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
binary_sensors: list[AirzoneBinarySensor] = [ added_systems: set[str] = set()
AirzoneSystemBinarySensor( added_zones: set[str] = set()
coordinator,
description,
entry,
system_id,
system_data,
)
for system_id, system_data in coordinator.data[AZD_SYSTEMS].items()
for description in SYSTEM_BINARY_SENSOR_TYPES
if description.key in system_data
]
binary_sensors.extend( def _async_entity_listener() -> None:
AirzoneZoneBinarySensor( """Handle additions of binary sensors."""
coordinator,
description,
entry,
system_zone_id,
zone_data,
)
for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items()
for description in ZONE_BINARY_SENSOR_TYPES
if description.key in zone_data
)
async_add_entities(binary_sensors) entities: list[AirzoneBinarySensor] = []
systems_data = coordinator.data.get(AZD_SYSTEMS, {})
received_systems = set(systems_data)
new_systems = received_systems - added_systems
if new_systems:
entities.extend(
AirzoneSystemBinarySensor(
coordinator,
description,
entry,
system_id,
systems_data.get(system_id),
)
for system_id in new_systems
for description in SYSTEM_BINARY_SENSOR_TYPES
if description.key in systems_data.get(system_id)
)
added_systems.update(new_systems)
zones_data = coordinator.data.get(AZD_ZONES, {})
received_zones = set(zones_data)
new_zones = received_zones - added_zones
if new_zones:
entities.extend(
AirzoneZoneBinarySensor(
coordinator,
description,
entry,
system_zone_id,
zones_data.get(system_zone_id),
)
for system_zone_id in new_zones
for description in ZONE_BINARY_SENSOR_TYPES
if description.key in zones_data.get(system_zone_id)
)
added_zones.update(new_zones)
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
class AirzoneBinarySensor(AirzoneEntity, BinarySensorEntity): class AirzoneBinarySensor(AirzoneEntity, BinarySensorEntity):

View File

@ -102,17 +102,31 @@ async def async_setup_entry(
entry: AirzoneConfigEntry, entry: AirzoneConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Add Airzone sensors from a config_entry.""" """Add Airzone climate from a config_entry."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
async_add_entities(
AirzoneClimate( added_zones: set[str] = set()
coordinator,
entry, def _async_entity_listener() -> None:
system_zone_id, """Handle additions of climate."""
zone_data,
) zones_data = coordinator.data.get(AZD_ZONES, {})
for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items() received_zones = set(zones_data)
) new_zones = received_zones - added_zones
if new_zones:
async_add_entities(
AirzoneClimate(
coordinator,
entry,
system_zone_id,
zones_data.get(system_zone_id),
)
for system_zone_id in new_zones
)
added_zones.update(new_zones)
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):

View File

@ -114,7 +114,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
) )
try: try:
await airzone.get_version() await airzone.get_version()
except AirzoneError as err: except (AirzoneError, TimeoutError) as err:
raise AbortFlow("cannot_connect") from err raise AbortFlow("cannot_connect") from err
return await self.async_step_discovered_connection() return await self.async_step_discovered_connection()

View File

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone", "documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairzone"], "loggers": ["aioairzone"],
"requirements": ["aioairzone==0.7.7"] "requirements": ["aioairzone==0.8.2"]
} }

View File

@ -83,21 +83,34 @@ async def async_setup_entry(
entry: AirzoneConfigEntry, entry: AirzoneConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Add Airzone sensors from a config_entry.""" """Add Airzone select from a config_entry."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
async_add_entities( added_zones: set[str] = set()
AirzoneZoneSelect(
coordinator, def _async_entity_listener() -> None:
description, """Handle additions of select."""
entry,
system_zone_id, zones_data = coordinator.data.get(AZD_ZONES, {})
zone_data, received_zones = set(zones_data)
) new_zones = received_zones - added_zones
for description in ZONE_SELECT_TYPES if new_zones:
for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items() async_add_entities(
if description.key in zone_data AirzoneZoneSelect(
) coordinator,
description,
entry,
system_zone_id,
zones_data.get(system_zone_id),
)
for system_zone_id in new_zones
for description in ZONE_SELECT_TYPES
if description.key in zones_data.get(system_zone_id)
)
added_zones.update(new_zones)
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
class AirzoneBaseSelect(AirzoneEntity, SelectEntity): class AirzoneBaseSelect(AirzoneEntity, SelectEntity):

View File

@ -85,21 +85,37 @@ async def async_setup_entry(
"""Add Airzone sensors from a config_entry.""" """Add Airzone sensors from a config_entry."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
sensors: list[AirzoneSensor] = [ added_zones: set[str] = set()
AirzoneZoneSensor(
coordinator, def _async_entity_listener() -> None:
description, """Handle additions of sensors."""
entry,
system_zone_id, entities: list[AirzoneSensor] = []
zone_data,
) zones_data = coordinator.data.get(AZD_ZONES, {})
for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items() received_zones = set(zones_data)
for description in ZONE_SENSOR_TYPES new_zones = received_zones - added_zones
if description.key in zone_data if new_zones:
] entities.extend(
AirzoneZoneSensor(
coordinator,
description,
entry,
system_zone_id,
zones_data.get(system_zone_id),
)
for system_zone_id in new_zones
for description in ZONE_SENSOR_TYPES
if description.key in zones_data.get(system_zone_id)
)
added_zones.update(new_zones)
async_add_entities(entities)
entities: list[AirzoneSensor] = []
if AZD_HOT_WATER in coordinator.data: if AZD_HOT_WATER in coordinator.data:
sensors.extend( entities.extend(
AirzoneHotWaterSensor( AirzoneHotWaterSensor(
coordinator, coordinator,
description, description,
@ -110,7 +126,7 @@ async def async_setup_entry(
) )
if AZD_WEBSERVER in coordinator.data: if AZD_WEBSERVER in coordinator.data:
sensors.extend( entities.extend(
AirzoneWebServerSensor( AirzoneWebServerSensor(
coordinator, coordinator,
description, description,
@ -120,7 +136,10 @@ async def async_setup_entry(
if description.key in coordinator.data[AZD_WEBSERVER] if description.key in coordinator.data[AZD_WEBSERVER]
) )
async_add_entities(sensors) async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
class AirzoneSensor(AirzoneEntity, SensorEntity): class AirzoneSensor(AirzoneEntity, SensorEntity):

View File

@ -61,7 +61,7 @@ async def async_setup_entry(
entry: AirzoneConfigEntry, entry: AirzoneConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Add Airzone sensors from a config_entry.""" """Add Airzone Water Heater from a config_entry."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
if AZD_HOT_WATER in coordinator.data: if AZD_HOT_WATER in coordinator.data:
async_add_entities([AirzoneWaterHeater(coordinator, entry)]) async_add_entities([AirzoneWaterHeater(coordinator, entry)])

View File

@ -14,6 +14,7 @@ from aioairzone_cloud.const import (
AZD_FLOOR_DEMAND, AZD_FLOOR_DEMAND,
AZD_PROBLEMS, AZD_PROBLEMS,
AZD_SYSTEMS, AZD_SYSTEMS,
AZD_THERMOSTAT_BATTERY_LOW,
AZD_WARNINGS, AZD_WARNINGS,
AZD_ZONES, AZD_ZONES,
) )
@ -88,6 +89,10 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]
key=AZD_AQ_ACTIVE, key=AZD_AQ_ACTIVE,
translation_key="air_quality_active", translation_key="air_quality_active",
), ),
AirzoneBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.BATTERY,
key=AZD_THERMOSTAT_BATTERY_LOW,
),
AirzoneBinarySensorEntityDescription( AirzoneBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.RUNNING, device_class=BinarySensorDeviceClass.RUNNING,
key=AZD_FLOOR_DEMAND, key=AZD_FLOOR_DEMAND,
@ -156,6 +161,11 @@ class AirzoneBinarySensor(AirzoneEntity, BinarySensorEntity):
entity_description: AirzoneBinarySensorEntityDescription entity_description: AirzoneBinarySensorEntityDescription
@property
def available(self) -> bool:
"""Return Airzone Cloud binary sensor availability."""
return super().available and self.is_on is not None
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator updates.""" """Update attributes when the coordinator updates."""

View File

@ -13,9 +13,12 @@ from aioairzone_cloud.const import (
AZD_GROUPS, AZD_GROUPS,
AZD_HOT_WATERS, AZD_HOT_WATERS,
AZD_INSTALLATIONS, AZD_INSTALLATIONS,
AZD_MODEL,
AZD_NAME, AZD_NAME,
AZD_SYSTEM_ID, AZD_SYSTEM_ID,
AZD_SYSTEMS, AZD_SYSTEMS,
AZD_THERMOSTAT_FW,
AZD_THERMOSTAT_MODEL,
AZD_WEBSERVER, AZD_WEBSERVER,
AZD_WEBSERVERS, AZD_WEBSERVERS,
AZD_ZONES, AZD_ZONES,
@ -69,6 +72,7 @@ class AirzoneAidooEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, aidoo_id)}, identifiers={(DOMAIN, aidoo_id)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=aidoo_data[AZD_MODEL],
name=aidoo_data[AZD_NAME], name=aidoo_data[AZD_NAME],
via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]), via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]),
) )
@ -111,6 +115,7 @@ class AirzoneGroupEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, group_id)}, identifiers={(DOMAIN, group_id)},
model="Group",
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
name=group_data[AZD_NAME], name=group_data[AZD_NAME],
) )
@ -154,6 +159,7 @@ class AirzoneHotWaterEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, dhw_id)}, identifiers={(DOMAIN, dhw_id)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model="Hot Water",
name=dhw_data[AZD_NAME], name=dhw_data[AZD_NAME],
via_device=(DOMAIN, dhw_data[AZD_WEBSERVER]), via_device=(DOMAIN, dhw_data[AZD_WEBSERVER]),
) )
@ -195,6 +201,7 @@ class AirzoneInstallationEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, inst_id)}, identifiers={(DOMAIN, inst_id)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model="Installation",
name=inst_data[AZD_NAME], name=inst_data[AZD_NAME],
) )
@ -240,9 +247,11 @@ class AirzoneSystemEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, system_id)}, identifiers={(DOMAIN, system_id)},
model=system_data.get(AZD_MODEL),
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
name=system_data[AZD_NAME], name=system_data[AZD_NAME],
via_device=(DOMAIN, system_data[AZD_WEBSERVER]), via_device=(DOMAIN, system_data[AZD_WEBSERVER]),
sw_version=system_data.get(AZD_FIRMWARE),
) )
def get_airzone_value(self, key: str) -> Any: def get_airzone_value(self, key: str) -> Any:
@ -270,6 +279,7 @@ class AirzoneWebServerEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, ws_id)}, connections={(dr.CONNECTION_NETWORK_MAC, ws_id)},
identifiers={(DOMAIN, ws_id)}, identifiers={(DOMAIN, ws_id)},
model="WebServer",
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
name=ws_data[AZD_NAME], name=ws_data[AZD_NAME],
sw_version=ws_data[AZD_FIRMWARE], sw_version=ws_data[AZD_FIRMWARE],
@ -300,9 +310,11 @@ class AirzoneZoneEntity(AirzoneEntity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, zone_id)}, identifiers={(DOMAIN, zone_id)},
model=zone_data.get(AZD_THERMOSTAT_MODEL),
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
name=zone_data[AZD_NAME], name=zone_data[AZD_NAME],
via_device=(DOMAIN, self.system_id), via_device=(DOMAIN, self.system_id),
sw_version=zone_data.get(AZD_THERMOSTAT_FW),
) )
def get_airzone_value(self, key: str) -> Any: def get_airzone_value(self, key: str) -> Any:

View File

@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"cpu_usage": {
"default": "mdi:cpu-32-bit"
},
"free_memory": {
"default": "mdi:memory"
},
"thermostat_coverage": {
"default": "mdi:signal"
}
}
}
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"], "loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.5.3"] "requirements": ["aioairzone-cloud==0.6.2"]
} }

View File

@ -10,8 +10,12 @@ from aioairzone_cloud.const import (
AZD_AQ_PM_1, AZD_AQ_PM_1,
AZD_AQ_PM_2P5, AZD_AQ_PM_2P5,
AZD_AQ_PM_10, AZD_AQ_PM_10,
AZD_CPU_USAGE,
AZD_HUMIDITY, AZD_HUMIDITY,
AZD_MEMORY_FREE,
AZD_TEMP, AZD_TEMP,
AZD_THERMOSTAT_BATTERY,
AZD_THERMOSTAT_COVERAGE,
AZD_WEBSERVERS, AZD_WEBSERVERS,
AZD_WIFI_RSSI, AZD_WIFI_RSSI,
AZD_ZONES, AZD_ZONES,
@ -28,6 +32,7 @@ from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory, EntityCategory,
UnitOfInformation,
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -52,6 +57,22 @@ AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
) )
WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
SensorEntityDescription(
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_CPU_USAGE,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="cpu_usage",
),
SensorEntityDescription(
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_MEMORY_FREE,
native_unit_of_measurement=UnitOfInformation.BYTES,
state_class=SensorStateClass.MEASUREMENT,
translation_key="free_memory",
),
SensorEntityDescription( SensorEntityDescription(
device_class=SensorDeviceClass.SIGNAL_STRENGTH, device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@ -98,6 +119,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
SensorEntityDescription(
device_class=SensorDeviceClass.BATTERY,
key=AZD_THERMOSTAT_BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_THERMOSTAT_COVERAGE,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="thermostat_coverage",
),
) )
@ -154,6 +189,11 @@ async def async_setup_entry(
class AirzoneSensor(AirzoneEntity, SensorEntity): class AirzoneSensor(AirzoneEntity, SensorEntity):
"""Define an Airzone Cloud sensor.""" """Define an Airzone Cloud sensor."""
@property
def available(self) -> bool:
"""Return Airzone Cloud sensor availability."""
return super().available and self.native_value is not None
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator updates.""" """Update attributes when the coordinator updates."""

View File

@ -37,6 +37,17 @@
"auto": "Auto" "auto": "Auto"
} }
} }
},
"sensor": {
"cpu_usage": {
"name": "CPU usage"
},
"free_memory": {
"name": "Free memory"
},
"thermostat_coverage": {
"name": "Signal percentage"
}
} }
} }
} }

View File

@ -2,93 +2,37 @@
from __future__ import annotations from __future__ import annotations
from genie_partner_sdk.client import AladdinConnectClient from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from .api import AsyncConfigEntryAuth DOMAIN = "aladdin_connect"
from .const import DOMAIN
from .coordinator import AladdinConnectCoordinator
PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR]
type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator]
async def async_setup_entry( async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
hass: HomeAssistant, entry: AladdinConnectConfigEntry """Set up Aladdin Connect from a config entry."""
) -> bool: ir.async_create_issue(
"""Set up Aladdin Connect Genie from a config entry.""" hass,
implementation = await async_get_config_entry_implementation(hass, entry) DOMAIN,
DOMAIN,
session = OAuth2Session(hass, entry, implementation) is_fixable=False,
auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session) severity=ir.IssueSeverity.ERROR,
coordinator = AladdinConnectCoordinator(hass, AladdinConnectClient(auth)) translation_key="integration_removed",
translation_placeholders={
await coordinator.async_setup() "entries": "/config/integrations/integration/aladdin_connect",
await coordinator.async_config_entry_first_refresh() },
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async_remove_stale_devices(hass, entry)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
) -> bool:
"""Migrate old config."""
if config_entry.version < 2:
config_entry.async_start_reauth(hass)
hass.config_entries.async_update_entry(
config_entry,
version=2,
minor_version=1,
)
return True
def async_remove_stale_devices(
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
) -> None:
"""Remove stale devices from device registry."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
) )
all_device_ids = {door.unique_id for door in config_entry.runtime_data.doors}
for device_entry in device_entries: return True
device_id: str | None = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
device_id = identifier[1]
break
if device_id is None or device_id not in all_device_ids: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# If device_id is None an invalid device entry was found for this config entry. """Unload a config entry."""
# If the device_id is not in existing device ids it's a stale device entry. if all(
# Remove config entry from this device entry in either case. config_entry.state is ConfigEntryState.NOT_LOADED
device_registry.async_update_device( for config_entry in hass.config_entries.async_entries(DOMAIN)
device_entry.id, remove_config_entry_id=config_entry.entry_id if config_entry.entry_id != entry.entry_id
) ):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
return True

View File

@ -1,32 +0,0 @@
"""API for Aladdin Connect Genie bound to Home Assistant OAuth."""
from typing import cast
from aiohttp import ClientSession
from genie_partner_sdk.auth import Auth
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
class AsyncConfigEntryAuth(Auth): # type: ignore[misc]
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: OAuth2Session,
) -> None:
"""Initialize Aladdin Connect Genie auth."""
super().__init__(
websession, API_URL, oauth_session.token["access_token"], API_KEY
)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])

View File

@ -1,70 +1,11 @@
"""Config flow for Aladdin Connect Genie.""" """Config flow for Aladdin Connect integration."""
from collections.abc import Mapping from homeassistant.config_entries import ConfigFlow
import logging
from typing import Any
import jwt from . import DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import DOMAIN
class AladdinConnectOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): class AladdinConnectConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow to handle Aladdin Connect Genie OAuth2 authentication.""" """Handle a config flow for Aladdin Connect."""
DOMAIN = DOMAIN VERSION = 1
VERSION = 2
MINOR_VERSION = 1
reauth_entry: ConfigEntry | None = None
async def async_step_reauth(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon API auth error or upgrade from v1 to v2."""
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: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
token_payload = jwt.decode(
data[CONF_TOKEN][CONF_ACCESS_TOKEN], options={"verify_signature": False}
)
if not self.reauth_entry:
await self.async_set_unique_id(token_payload["sub"])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=token_payload["username"],
data=data,
)
if self.reauth_entry.unique_id == token_payload["username"]:
return self.async_update_reload_and_abort(
self.reauth_entry,
data=data,
unique_id=token_payload["sub"],
)
if self.reauth_entry.unique_id == token_payload["sub"]:
return self.async_update_reload_and_abort(self.reauth_entry, data=data)
return self.async_abort(reason="wrong_account")
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)

View File

@ -1,6 +0,0 @@
"""Constants for the Aladdin Connect Genie integration."""
DOMAIN = "aladdin_connect"
OAUTH2_AUTHORIZE = "https://app.aladdinconnect.net/login.html"
OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token"

View File

@ -1,38 +0,0 @@
"""Define an object to coordinate fetching Aladdin Connect data."""
from datetime import timedelta
import logging
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class AladdinConnectCoordinator(DataUpdateCoordinator[None]):
"""Aladdin Connect Data Update Coordinator."""
def __init__(self, hass: HomeAssistant, acc: AladdinConnectClient) -> None:
"""Initialize."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=15),
)
self.acc = acc
self.doors: list[GarageDoor] = []
async def async_setup(self) -> None:
"""Fetch initial data."""
self.doors = await self.acc.get_doors()
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
for door in self.doors:
await self.acc.update_door(door.device_id, door.door_number)

View File

@ -1,84 +0,0 @@
"""Cover Entity for Genie Garage Door."""
from typing import Any
from genie_partner_sdk.model import GarageDoor
from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AladdinConnectConfigEntry, AladdinConnectCoordinator
from .entity import AladdinConnectEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AladdinConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Aladdin Connect platform."""
coordinator = config_entry.runtime_data
async_add_entities(AladdinDevice(coordinator, door) for door in coordinator.doors)
class AladdinDevice(AladdinConnectEntity, CoverEntity):
"""Representation of Aladdin Connect cover."""
_attr_device_class = CoverDeviceClass.GARAGE
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
_attr_name = None
def __init__(
self, coordinator: AladdinConnectCoordinator, device: GarageDoor
) -> None:
"""Initialize the Aladdin Connect cover."""
super().__init__(coordinator, device)
self._attr_unique_id = device.unique_id
async def async_open_cover(self, **kwargs: Any) -> None:
"""Issue open command to cover."""
await self.coordinator.acc.open_door(
self._device.device_id, self._device.door_number
)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Issue close command to cover."""
await self.coordinator.acc.close_door(
self._device.device_id, self._device.door_number
)
@property
def is_closed(self) -> bool | None:
"""Update is closed attribute."""
value = self.coordinator.acc.get_door_status(
self._device.device_id, self._device.door_number
)
if value is None:
return None
return bool(value == "closed")
@property
def is_closing(self) -> bool | None:
"""Update is closing attribute."""
value = self.coordinator.acc.get_door_status(
self._device.device_id, self._device.door_number
)
if value is None:
return None
return bool(value == "closing")
@property
def is_opening(self) -> bool | None:
"""Update is opening attribute."""
value = self.coordinator.acc.get_door_status(
self._device.device_id, self._device.door_number
)
if value is None:
return None
return bool(value == "opening")

View File

@ -1,27 +0,0 @@
"""Defines a base Aladdin Connect entity."""
from genie_partner_sdk.model import GarageDoor
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AladdinConnectCoordinator
class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
"""Defines a base Aladdin Connect entity."""
_attr_has_entity_name = True
def __init__(
self, coordinator: AladdinConnectCoordinator, device: GarageDoor
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._device = device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
name=device.name,
manufacturer="Overhead Door",
)

View File

@ -1,10 +1,9 @@
{ {
"domain": "aladdin_connect", "domain": "aladdin_connect",
"name": "Aladdin Connect", "name": "Aladdin Connect",
"codeowners": ["@swcloudgenie"], "codeowners": [],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
"integration_type": "system",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["genie-partner-sdk==1.0.2"] "requirements": []
} }

View File

@ -1,80 +0,0 @@
"""Support for Aladdin Connect Garage Door sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AladdinConnectConfigEntry, AladdinConnectCoordinator
from .entity import AladdinConnectEntity
@dataclass(frozen=True, kw_only=True)
class AccSensorEntityDescription(SensorEntityDescription):
"""Describes AladdinConnect sensor entity."""
value_fn: Callable[[AladdinConnectClient, str, int], float | None]
SENSORS: tuple[AccSensorEntityDescription, ...] = (
AccSensorEntityDescription(
key="battery_level",
device_class=SensorDeviceClass.BATTERY,
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=AladdinConnectClient.get_battery_status,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Aladdin Connect sensor devices."""
coordinator = entry.runtime_data
async_add_entities(
AladdinConnectSensor(coordinator, door, description)
for description in SENSORS
for door in coordinator.doors
)
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
"""A sensor implementation for Aladdin Connect devices."""
entity_description: AccSensorEntityDescription
def __init__(
self,
coordinator: AladdinConnectCoordinator,
device: GarageDoor,
description: AccSensorEntityDescription,
) -> None:
"""Initialize a sensor for an Aladdin Connect device."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{device.unique_id}-{description.key}"
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(
self.coordinator.acc, self._device.device_id, self._device.door_number
)

View File

@ -1,29 +1,8 @@
{ {
"config": { "issues": {
"step": { "integration_removed": {
"pick_implementation": { "title": "The Aladdin Connect integration has been removed",
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" "description": "The Aladdin Connect integration has been removed from Home Assistant.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Aladdin Connect integration entries]({entries})."
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Aladdin Connect needs to re-authenticate your account"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
} }
} }
} }

View File

@ -52,8 +52,10 @@ from .const import ( # noqa: F401
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
SCAN_INTERVAL: Final = timedelta(seconds=30)
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
PLATFORM_SCHEMA: Final = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE: Final = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL: Final = timedelta(seconds=30)
CONF_DEFAULT_CODE = "default_code" CONF_DEFAULT_CODE = "default_code"
@ -61,8 +63,6 @@ ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema(
{vol.Optional(ATTR_CODE): cv.string} {vol.Optional(ATTR_CODE): cv.string}
) )
PLATFORM_SCHEMA: Final = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE: Final = cv.PLATFORM_SCHEMA_BASE
# mypy: disallow-any-generics # mypy: disallow-any-generics

View File

@ -1,14 +1,15 @@
# Describes the format for available alarm control panel services # Describes the format for available alarm control panel services
.common_service_fields: &common_service_fields
code:
example: "1234"
selector:
text:
alarm_disarm: alarm_disarm:
target: target:
entity: entity:
domain: alarm_control_panel domain: alarm_control_panel
fields: fields: *common_service_fields
code:
example: "1234"
selector:
text:
alarm_arm_custom_bypass: alarm_arm_custom_bypass:
target: target:
@ -16,11 +17,7 @@ alarm_arm_custom_bypass:
domain: alarm_control_panel domain: alarm_control_panel
supported_features: supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
fields: fields: *common_service_fields
code:
example: "1234"
selector:
text:
alarm_arm_home: alarm_arm_home:
target: target:
@ -28,11 +25,7 @@ alarm_arm_home:
domain: alarm_control_panel domain: alarm_control_panel
supported_features: supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
fields: fields: *common_service_fields
code:
example: "1234"
selector:
text:
alarm_arm_away: alarm_arm_away:
target: target:
@ -40,23 +33,14 @@ alarm_arm_away:
domain: alarm_control_panel domain: alarm_control_panel
supported_features: supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
fields: fields: *common_service_fields
code:
example: "1234"
selector:
text:
alarm_arm_night: alarm_arm_night:
target: target:
entity: entity:
domain: alarm_control_panel domain: alarm_control_panel
supported_features: supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
fields: fields: *common_service_fields
code:
example: "1234"
selector:
text:
alarm_arm_vacation: alarm_arm_vacation:
target: target:
@ -64,11 +48,7 @@ alarm_arm_vacation:
domain: alarm_control_panel domain: alarm_control_panel
supported_features: supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
fields: fields: *common_service_fields
code:
example: "1234"
selector:
text:
alarm_trigger: alarm_trigger:
target: target:
@ -76,8 +56,4 @@ alarm_trigger:
domain: alarm_control_panel domain: alarm_control_panel
supported_features: supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.TRIGGER - alarm_control_panel.AlarmControlPanelEntityFeature.TRIGGER
fields: fields: *common_service_fields
code:
example: "1234"
selector:
text:

View File

@ -124,9 +124,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if not entities: if not entities:
return False return False
component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off")
component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on")
component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle")
await component.async_add_entities(entities) await component.async_add_entities(entities)
@ -162,16 +162,8 @@ class Alert(Entity):
self._data = data self._data = data
self._message_template = message_template self._message_template = message_template
if self._message_template is not None:
self._message_template.hass = hass
self._done_message_template = done_message_template self._done_message_template = done_message_template
if self._done_message_template is not None:
self._done_message_template.hass = hass
self._title_template = title_template self._title_template = title_template
if self._title_template is not None:
self._title_template.hass = hass
self._notifiers = notifiers self._notifiers = notifiers
self._can_ack = can_ack self._can_ack = can_ack

View File

@ -2,11 +2,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator
import logging import logging
from typing import Any from typing import Any
from typing_extensions import Generator
from homeassistant.components import ( from homeassistant.components import (
button, button,
climate, climate,
@ -19,6 +18,7 @@ from homeassistant.components import (
light, light,
media_player, media_player,
number, number,
remote,
timer, timer,
vacuum, vacuum,
valve, valve,
@ -439,6 +439,8 @@ class AlexaPowerController(AlexaCapability):
is_on = self.entity.state == fan.STATE_ON is_on = self.entity.state == fan.STATE_ON
elif self.entity.domain == humidifier.DOMAIN: elif self.entity.domain == humidifier.DOMAIN:
is_on = self.entity.state == humidifier.STATE_ON is_on = self.entity.state == humidifier.STATE_ON
elif self.entity.domain == remote.DOMAIN:
is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN)
elif self.entity.domain == vacuum.DOMAIN: elif self.entity.domain == vacuum.DOMAIN:
is_on = self.entity.state == vacuum.STATE_CLEANING is_on = self.entity.state == vacuum.STATE_CLEANING
elif self.entity.domain == timer.DOMAIN: elif self.entity.domain == timer.DOMAIN:
@ -1436,6 +1438,12 @@ class AlexaModeController(AlexaCapability):
if mode in modes: if mode in modes:
return f"{humidifier.ATTR_MODE}.{mode}" return f"{humidifier.ATTR_MODE}.{mode}"
# Remote Activity
if self.instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}":
activity = self.entity.attributes.get(remote.ATTR_CURRENT_ACTIVITY, None)
if activity in self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST, []):
return f"{remote.ATTR_ACTIVITY}.{activity}"
# Water heater operation mode # Water heater operation mode
if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
operation_mode = self.entity.attributes.get( operation_mode = self.entity.attributes.get(
@ -1550,6 +1558,24 @@ class AlexaModeController(AlexaCapability):
) )
return self._resource.serialize_capability_resources() return self._resource.serialize_capability_resources()
# Remote Resource
if self.instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}":
# Use the mode controller for a remote because the input controller
# only allows a preset of names as an input.
self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False)
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
for activity in activities:
self._resource.add_mode(
f"{remote.ATTR_ACTIVITY}.{activity}", [activity]
)
# Remotes with a single activity completely break Alexa discovery, add a
# fake activity to the mode controller (see issue #53832).
if len(activities) == 1:
self._resource.add_mode(
f"{remote.ATTR_ACTIVITY}.{PRESET_MODE_NA}", [PRESET_MODE_NA]
)
return self._resource.serialize_capability_resources()
# Cover Position Resources # Cover Position Resources
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
self._resource = AlexaModeResource( self._resource = AlexaModeResource(

View File

@ -88,7 +88,7 @@ API_THERMOSTAT_MODES_CUSTOM = {
API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"}
# AlexaModeController does not like a single mode for the fan preset or humidifier mode, # AlexaModeController does not like a single mode for the fan preset or humidifier mode,
# we add PRESET_MODE_NA if a fan / humidifier has only one preset_mode # we add PRESET_MODE_NA if a fan / humidifier / remote has only one preset_mode
PRESET_MODE_NA = "-" PRESET_MODE_NA = "-"
STORAGE_ACCESS_TOKEN = "access_token" STORAGE_ACCESS_TOKEN = "access_token"

View File

@ -2,12 +2,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Generator, Iterable
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from typing_extensions import Generator
from homeassistant.components import ( from homeassistant.components import (
alarm_control_panel, alarm_control_panel,
alert, alert,
@ -29,6 +27,7 @@ from homeassistant.components import (
lock, lock,
media_player, media_player,
number, number,
remote,
scene, scene,
script, script,
sensor, sensor,
@ -198,6 +197,10 @@ class DisplayCategory:
# Indicates a device that prints. # Indicates a device that prints.
PRINTER = "PRINTER" PRINTER = "PRINTER"
# Indicates a decive that support stateless events,
# such as remote switches and smart buttons.
REMOTE = "REMOTE"
# Indicates a network router. # Indicates a network router.
ROUTER = "ROUTER" ROUTER = "ROUTER"
@ -647,6 +650,24 @@ class FanCapabilities(AlexaEntity):
yield Alexa(self.entity) yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(remote.DOMAIN)
class RemoteCapabilities(AlexaEntity):
"""Class to represent Remote capabilities."""
def default_display_categories(self) -> list[str]:
"""Return the display categories for this entity."""
return [DisplayCategory.REMOTE]
def interfaces(self) -> Generator[AlexaCapability]:
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
yield AlexaModeController(
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
)
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(humidifier.DOMAIN) @ENTITY_ADAPTERS.register(humidifier.DOMAIN)
class HumidifierCapabilities(AlexaEntity): class HumidifierCapabilities(AlexaEntity):
"""Class to represent Humidifier capabilities.""" """Class to represent Humidifier capabilities."""

View File

@ -52,7 +52,6 @@ class AlexaFlashBriefingView(http.HomeAssistantView):
"""Initialize Alexa view.""" """Initialize Alexa view."""
super().__init__() super().__init__()
self.flash_briefings = flash_briefings self.flash_briefings = flash_briefings
template.attach(hass, self.flash_briefings)
@callback @callback
def get( def get(

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