Compare commits

..

4 Commits

Author SHA1 Message Date
Ludovic BOUÉ
e2a1e6c9d7 Add services/actions 2025-09-26 14:37:57 +00:00
Ludovic BOUÉ
85de4955b5 Add occupancy attribute 2025-09-26 12:44:01 +00:00
Ludovic BOUÉ
79f1710ad7 Testing Occupancy feature 2025-09-26 12:34:58 +00:00
Ludovic BOUÉ
8667f1750a Add unoccupied setpoints 2025-09-26 12:21:57 +00:00
3941 changed files with 92806 additions and 244426 deletions

View File

@@ -33,7 +33,7 @@
"GitHub.vscode-pull-request-github", "GitHub.vscode-pull-request-github",
"GitHub.copilot" "GitHub.copilot"
], ],
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.jsonc // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json
"settings": { "settings": {
"python.experiments.optOutFrom": ["pythonTestAdapter"], "python.experiments.optOutFrom": ["pythonTestAdapter"],
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python", "python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
@@ -41,7 +41,6 @@
"python.terminal.activateEnvInCurrentTerminal": true, "python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"], "python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment", "pylint.importStrategy": "fromEnvironment",
"python.analysis.typeCheckingMode": "basic",
"editor.formatOnPaste": false, "editor.formatOnPaste": false,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnType": true, "editor.formatOnType": true,
@@ -63,9 +62,6 @@
"[python]": { "[python]": {
"editor.defaultFormatter": "charliermarsh.ruff" "editor.defaultFormatter": "charliermarsh.ruff"
}, },
"[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"json.schemas": [ "json.schemas": [
{ {
"fileMatch": ["homeassistant/components/*/manifest.json"], "fileMatch": ["homeassistant/components/*/manifest.json"],

View File

@@ -74,7 +74,6 @@ rules:
- **Formatting**: Ruff - **Formatting**: Ruff
- **Linting**: PyLint and Ruff - **Linting**: PyLint and Ruff
- **Type Checking**: MyPy - **Type Checking**: MyPy
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
- **Testing**: pytest with plain functions and fixtures - **Testing**: pytest with plain functions and fixtures
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles) - **Language**: American English for all code, comments, and documentation (use sentence case, including titles)

View File

@@ -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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: translations name: translations
path: translations.tar.gz path: translations.tar.gz
@@ -88,10 +88,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
exclude:
- arch: armv7
- arch: armhf
- arch: i386
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -166,8 +162,20 @@ jobs:
sed -i "s|home-assistant-intents==.*||" requirements_all.txt sed -i "s|home-assistant-intents==.*||" requirements_all.txt
fi fi
- name: Adjustments for armhf
if: matrix.arch == 'armhf'
run: |
# Pandas has issues building on armhf, it is expected they
# will drop the platform in the near future (they consider it
# "flimsy" on 386). The following packages depend on pandas,
# so we comment them out.
sed -i "s|env-canada|# env-canada|g" requirements_all.txt
sed -i "s|noaa-coops|# noaa-coops|g" requirements_all.txt
sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations - name: Download translations
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with: with:
name: translations name: translations
@@ -182,7 +190,7 @@ 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@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -218,11 +226,19 @@ jobs:
- odroid-c4 - odroid-c4
- odroid-m1 - odroid-m1
- odroid-n2 - odroid-n2
- odroid-xu
- qemuarm
- qemuarm-64 - qemuarm-64
- qemux86
- qemux86-64 - qemux86-64
- raspberrypi
- raspberrypi2
- raspberrypi3
- raspberrypi3-64 - raspberrypi3-64
- raspberrypi4
- raspberrypi4-64 - raspberrypi4-64
- raspberrypi5-64 - raspberrypi5-64
- tinker
- yellow - yellow
- green - green
steps: steps:
@@ -241,7 +257,7 @@ jobs:
fi fi
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -281,7 +297,6 @@ jobs:
key-description: "Home Assistant Core" key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }} version: ${{ needs.init.outputs.version }}
channel: ${{ needs.init.outputs.channel }} channel: ${{ needs.init.outputs.channel }}
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
- name: Update version file (stable -> beta) - name: Update version file (stable -> beta)
if: needs.init.outputs.channel == 'stable' if: needs.init.outputs.channel == 'stable'
@@ -291,7 +306,6 @@ jobs:
key-description: "Home Assistant Core" key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }} version: ${{ needs.init.outputs.version }}
channel: beta channel: beta
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container: publish_container:
name: Publish meta container for ${{ matrix.registry }} name: Publish meta container for ${{ matrix.registry }}
@@ -312,20 +326,20 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.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@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.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@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -343,12 +357,27 @@ jobs:
docker manifest create "${registry}/home-assistant:${tag_l}" \ docker manifest create "${registry}/home-assistant:${tag_l}" \
"${registry}/amd64-homeassistant:${tag_r}" \ "${registry}/amd64-homeassistant:${tag_r}" \
"${registry}/i386-homeassistant:${tag_r}" \
"${registry}/armhf-homeassistant:${tag_r}" \
"${registry}/armv7-homeassistant:${tag_r}" \
"${registry}/aarch64-homeassistant:${tag_r}" "${registry}/aarch64-homeassistant:${tag_r}"
docker manifest annotate "${registry}/home-assistant:${tag_l}" \ docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/amd64-homeassistant:${tag_r}" \ "${registry}/amd64-homeassistant:${tag_r}" \
--os linux --arch amd64 --os linux --arch amd64
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/i386-homeassistant:${tag_r}" \
--os linux --arch 386
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/armhf-homeassistant:${tag_r}" \
--os linux --arch arm --variant=v6
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/armv7-homeassistant:${tag_r}" \
--os linux --arch arm --variant=v7
docker manifest annotate "${registry}/home-assistant:${tag_l}" \ docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/aarch64-homeassistant:${tag_r}" \ "${registry}/aarch64-homeassistant:${tag_r}" \
--os linux --arch arm64 --variant=v8 --os linux --arch arm64 --variant=v8
@@ -376,14 +405,23 @@ jobs:
# Pull images from github container registry and verify signature # Pull images from github container registry and verify signature
docker pull "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}" docker pull "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}"
docker pull "ghcr.io/home-assistant/i386-homeassistant:${{ needs.init.outputs.version }}"
docker pull "ghcr.io/home-assistant/armhf-homeassistant:${{ needs.init.outputs.version }}"
docker pull "ghcr.io/home-assistant/armv7-homeassistant:${{ needs.init.outputs.version }}"
docker pull "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}" docker pull "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}"
validate_image "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}" validate_image "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}"
validate_image "ghcr.io/home-assistant/i386-homeassistant:${{ needs.init.outputs.version }}"
validate_image "ghcr.io/home-assistant/armhf-homeassistant:${{ needs.init.outputs.version }}"
validate_image "ghcr.io/home-assistant/armv7-homeassistant:${{ needs.init.outputs.version }}"
validate_image "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}" validate_image "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}"
if [[ "${{ matrix.registry }}" == "docker.io/homeassistant" ]]; then if [[ "${{ matrix.registry }}" == "docker.io/homeassistant" ]]; then
# Upload images to dockerhub # Upload images to dockerhub
push_dockerhub "amd64-homeassistant" "${{ needs.init.outputs.version }}" push_dockerhub "amd64-homeassistant" "${{ needs.init.outputs.version }}"
push_dockerhub "i386-homeassistant" "${{ needs.init.outputs.version }}"
push_dockerhub "armhf-homeassistant" "${{ needs.init.outputs.version }}"
push_dockerhub "armv7-homeassistant" "${{ needs.init.outputs.version }}"
push_dockerhub "aarch64-homeassistant" "${{ needs.init.outputs.version }}" push_dockerhub "aarch64-homeassistant" "${{ needs.init.outputs.version }}"
fi fi
@@ -426,7 +464,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations - name: Download translations
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with: with:
name: translations name: translations
@@ -466,7 +504,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}

File diff suppressed because it is too large Load Diff

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -17,7 +17,7 @@ jobs:
# - No PRs marked as no-stale # - No PRs marked as no-stale
# - No issues (-1) # - No issues (-1)
- name: 60 days stale PRs policy - name: 60 days stale PRs policy
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60 days-before-stale: 60
@@ -57,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted # - No issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: 90 days stale issues - name: 90 days stale issues
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90 days-before-stale: 90
@@ -87,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted # - No Issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: Needs more information stale issues policy - name: Needs more information stale issues policy
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information" only-labels: "needs-more-information"

View File

@@ -31,8 +31,7 @@ jobs:
outputs: outputs:
architectures: ${{ steps.info.outputs.architectures }} architectures: ${{ steps.info.outputs.architectures }}
steps: steps:
- &checkout - name: Checkout the repository
name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
@@ -92,7 +91,7 @@ jobs:
) > build_constraints.txt ) > build_constraints.txt
- name: Upload env_file - name: Upload env_file
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: env_file name: env_file
path: ./.env_file path: ./.env_file
@@ -100,14 +99,14 @@ jobs:
overwrite: true overwrite: true
- name: Upload build_constraints - name: Upload build_constraints
uses: *actions-upload-artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: build_constraints name: build_constraints
path: ./build_constraints.txt path: ./build_constraints.txt
overwrite: true overwrite: true
- name: Upload requirements_diff - name: Upload requirements_diff
uses: *actions-upload-artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: requirements_diff name: requirements_diff
path: ./requirements_diff.txt path: ./requirements_diff.txt
@@ -119,7 +118,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 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: requirements_all_wheels name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt path: ./requirements_all_wheels_*.txt
@@ -128,41 +127,28 @@ jobs:
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2) name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
needs: init needs: init
runs-on: ${{ matrix.os }} runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: &matrix-build matrix:
abi: ["cp313", "cp314"] abi: ["cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
include:
- os: ubuntu-latest
- arch: aarch64
os: ubuntu-24.04-arm
exclude:
- abi: cp314
arch: armv7
- abi: cp314
arch: armhf
- abi: cp314
arch: i386
steps: steps:
- *checkout - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- &download-env-file - name: Download env_file
name: Download env_file uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: env_file name: env_file
- &download-build-constraints - name: Download build_constraints
name: Download build_constraints uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: *actions-download-artifact
with: with:
name: build_constraints name: build_constraints
- &download-requirements-diff - name: Download requirements_diff
name: Download requirements_diff uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: *actions-download-artifact
with: with:
name: requirements_diff name: requirements_diff
@@ -174,7 +160,7 @@ jobs:
# home-assistant/wheels doesn't support sha pinning # home-assistant/wheels doesn't support sha pinning
- name: Build wheels - name: Build wheels
uses: &home-assistant-wheels home-assistant/wheels@2025.10.0 uses: home-assistant/wheels@2025.09.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@@ -191,19 +177,33 @@ jobs:
name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }}
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
needs: init needs: init
runs-on: ${{ matrix.os }} runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: *matrix-build matrix:
abi: ["cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- *checkout - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- *download-env-file - name: Download env_file
- *download-build-constraints uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
- *download-requirements-diff with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: requirements_diff
- name: Download requirements_all_wheels - name: Download requirements_all_wheels
uses: *actions-download-artifact uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with: with:
name: requirements_all_wheels name: requirements_all_wheels
@@ -221,14 +221,14 @@ jobs:
# home-assistant/wheels doesn't support sha pinning # home-assistant/wheels doesn't support sha pinning
- name: Build wheels - name: Build wheels
uses: *home-assistant-wheels uses: home-assistant/wheels@2025.09.1
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
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;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-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;zlib-ng-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;nasm;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"

3
.gitignore vendored
View File

@@ -79,6 +79,7 @@ junit.xml
.project .project
.pydevproject .pydevproject
.python-version
.tool-versions .tool-versions
# emacs auto backups # emacs auto backups
@@ -111,7 +112,6 @@ virtualization/vagrant/config
!.vscode/cSpell.json !.vscode/cSpell.json
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/settings.default.jsonc
.env .env
# Windows Explorer # Windows Explorer
@@ -141,5 +141,4 @@ pytest_buckets.txt
# AI tooling # AI tooling
.claude/settings.local.json .claude/settings.local.json
.serena/

View File

@@ -33,13 +33,10 @@ repos:
rev: v1.37.1 rev: v1.37.1
hooks: hooks:
- id: yamllint - id: yamllint
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.6.2 rev: v3.0.3
hooks: hooks:
- id: prettier - id: prettier
additional_dependencies:
- prettier@3.6.2
- prettier-plugin-sort-json@4.1.1
- repo: https://github.com/cdce8p/python-typing-update - repo: https://github.com/cdce8p/python-typing-update
rev: v0.6.0 rev: v0.6.0
hooks: hooks:

View File

@@ -1,24 +0,0 @@
/** @type {import("prettier").Config} */
module.exports = {
overrides: [
{
files: "./homeassistant/**/*.json",
options: {
plugins: [require.resolve("prettier-plugin-sort-json")],
jsonRecursiveSort: true,
jsonSortOrder: JSON.stringify({ [/.*/]: "numeric" }),
},
},
{
files: ["manifest.json", "./**/brands/*.json"],
options: {
// domain and name should stay at the top
jsonSortOrder: JSON.stringify({
domain: null,
name: null,
[/.*/]: "numeric",
}),
},
},
],
};

View File

@@ -1 +0,0 @@
3.13

View File

@@ -107,7 +107,6 @@ homeassistant.components.automation.*
homeassistant.components.awair.* homeassistant.components.awair.*
homeassistant.components.axis.* homeassistant.components.axis.*
homeassistant.components.azure_storage.* homeassistant.components.azure_storage.*
homeassistant.components.backblaze_b2.*
homeassistant.components.backup.* homeassistant.components.backup.*
homeassistant.components.baf.* homeassistant.components.baf.*
homeassistant.components.bang_olufsen.* homeassistant.components.bang_olufsen.*
@@ -183,6 +182,7 @@ homeassistant.components.efergy.*
homeassistant.components.eheimdigital.* homeassistant.components.eheimdigital.*
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.*
@@ -203,7 +203,6 @@ homeassistant.components.feedreader.*
homeassistant.components.file_upload.* homeassistant.components.file_upload.*
homeassistant.components.filesize.* homeassistant.components.filesize.*
homeassistant.components.filter.* homeassistant.components.filter.*
homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.* homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.* homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.* homeassistant.components.flux_led.*
@@ -221,7 +220,6 @@ homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.* homeassistant.components.geo_location.*
homeassistant.components.geocaching.* homeassistant.components.geocaching.*
homeassistant.components.gios.* homeassistant.components.gios.*
homeassistant.components.github.*
homeassistant.components.glances.* homeassistant.components.glances.*
homeassistant.components.go2rtc.* homeassistant.components.go2rtc.*
homeassistant.components.goalzero.* homeassistant.components.goalzero.*
@@ -279,7 +277,6 @@ homeassistant.components.imap.*
homeassistant.components.imgw_pib.* homeassistant.components.imgw_pib.*
homeassistant.components.immich.* homeassistant.components.immich.*
homeassistant.components.incomfort.* homeassistant.components.incomfort.*
homeassistant.components.inels.*
homeassistant.components.input_button.* homeassistant.components.input_button.*
homeassistant.components.input_select.* homeassistant.components.input_select.*
homeassistant.components.input_text.* homeassistant.components.input_text.*
@@ -328,7 +325,6 @@ homeassistant.components.london_underground.*
homeassistant.components.lookin.* homeassistant.components.lookin.*
homeassistant.components.lovelace.* homeassistant.components.lovelace.*
homeassistant.components.luftdaten.* homeassistant.components.luftdaten.*
homeassistant.components.lunatone.*
homeassistant.components.madvr.* homeassistant.components.madvr.*
homeassistant.components.manual.* homeassistant.components.manual.*
homeassistant.components.mastodon.* homeassistant.components.mastodon.*
@@ -396,6 +392,7 @@ homeassistant.components.otbr.*
homeassistant.components.overkiz.* homeassistant.components.overkiz.*
homeassistant.components.overseerr.* homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.* homeassistant.components.p1_monitor.*
homeassistant.components.pandora.*
homeassistant.components.panel_custom.* homeassistant.components.panel_custom.*
homeassistant.components.paperless_ngx.* homeassistant.components.paperless_ngx.*
homeassistant.components.peblar.* homeassistant.components.peblar.*
@@ -478,7 +475,6 @@ homeassistant.components.skybell.*
homeassistant.components.slack.* homeassistant.components.slack.*
homeassistant.components.sleep_as_android.* homeassistant.components.sleep_as_android.*
homeassistant.components.sleepiq.* homeassistant.components.sleepiq.*
homeassistant.components.sma.*
homeassistant.components.smhi.* homeassistant.components.smhi.*
homeassistant.components.smlight.* homeassistant.components.smlight.*
homeassistant.components.smtp.* homeassistant.components.smtp.*
@@ -557,7 +553,6 @@ homeassistant.components.vacuum.*
homeassistant.components.vallox.* homeassistant.components.vallox.*
homeassistant.components.valve.* homeassistant.components.valve.*
homeassistant.components.velbus.* homeassistant.components.velbus.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.* homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.* homeassistant.components.vodafone_station.*
homeassistant.components.volvo.* homeassistant.components.volvo.*

View File

@@ -7,19 +7,13 @@
"python.testing.pytestEnabled": false, "python.testing.pytestEnabled": false,
// https://code.visualstudio.com/docs/python/linting#_general-settings // https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment", "pylint.importStrategy": "fromEnvironment",
// Pyright is too pedantic for Home Assistant
"python.analysis.typeCheckingMode": "basic",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
},
"[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"json.schemas": [ "json.schemas": [
{ {
"fileMatch": ["homeassistant/components/*/manifest.json"], "fileMatch": [
// This value differs between working with devcontainer and locally, therefore this value should NOT be in sync! "homeassistant/components/*/manifest.json"
"url": "./script/json_schemas/manifest_schema.json", ],
}, // This value differs between working with devcontainer and locally, therefor this value should NOT be in sync!
], "url": "./script/json_schemas/manifest_schema.json"
}
]
} }

View File

@@ -1 +0,0 @@
.github/copilot-instructions.md

50
CODEOWNERS generated
View File

@@ -46,8 +46,6 @@ build.json @home-assistant/supervisor
/tests/components/accuweather/ @bieniu /tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray /homeassistant/components/acmeda/ @atmurray
/tests/components/acmeda/ @atmurray /tests/components/acmeda/ @atmurray
/homeassistant/components/actron_air/ @kclif9 @JagadishDhanamjayam
/tests/components/actron_air/ @kclif9 @JagadishDhanamjayam
/homeassistant/components/adax/ @danielhiversen @lazytarget /homeassistant/components/adax/ @danielhiversen @lazytarget
/tests/components/adax/ @danielhiversen @lazytarget /tests/components/adax/ @danielhiversen @lazytarget
/homeassistant/components/adguard/ @frenck /homeassistant/components/adguard/ @frenck
@@ -196,8 +194,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/azure_service_bus/ @hfurubotten /homeassistant/components/azure_service_bus/ @hfurubotten
/homeassistant/components/azure_storage/ @zweckj /homeassistant/components/azure_storage/ @zweckj
/tests/components/azure_storage/ @zweckj /tests/components/azure_storage/ @zweckj
/homeassistant/components/backblaze_b2/ @hugo-vrijswijk @ElCruncharino
/tests/components/backblaze_b2/ @hugo-vrijswijk @ElCruncharino
/homeassistant/components/backup/ @home-assistant/core /homeassistant/components/backup/ @home-assistant/core
/tests/components/backup/ @home-assistant/core /tests/components/backup/ @home-assistant/core
/homeassistant/components/baf/ @bdraco @jfroy /homeassistant/components/baf/ @bdraco @jfroy
@@ -318,6 +314,8 @@ build.json @home-assistant/supervisor
/tests/components/cpuspeed/ @fabaff /tests/components/cpuspeed/ @fabaff
/homeassistant/components/crownstone/ @Crownstone @RicArch97 /homeassistant/components/crownstone/ @Crownstone @RicArch97
/tests/components/crownstone/ @Crownstone @RicArch97 /tests/components/crownstone/ @Crownstone @RicArch97
/homeassistant/components/cups/ @fabaff
/tests/components/cups/ @fabaff
/homeassistant/components/cync/ @Kinachi249 /homeassistant/components/cync/ @Kinachi249
/tests/components/cync/ @Kinachi249 /tests/components/cync/ @Kinachi249
/homeassistant/components/daikin/ @fredrike /homeassistant/components/daikin/ @fredrike
@@ -494,10 +492,6 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST /tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes /homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes /tests/components/filter/ @dgomes
/homeassistant/components/fing/ @Lorenzo-Gasparini
/tests/components/fing/ @Lorenzo-Gasparini
/homeassistant/components/firefly_iii/ @erwindouna
/tests/components/firefly_iii/ @erwindouna
/homeassistant/components/fireservicerota/ @cyberjunky /homeassistant/components/fireservicerota/ @cyberjunky
/tests/components/fireservicerota/ @cyberjunky /tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP /homeassistant/components/firmata/ @DaAwesomeP
@@ -510,6 +504,8 @@ build.json @home-assistant/supervisor
/tests/components/fjaraskupan/ @elupus /tests/components/fjaraskupan/ @elupus
/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski /homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski
/tests/components/flexit_bacnet/ @lellky @piotrbulinski /tests/components/flexit_bacnet/ @lellky @piotrbulinski
/homeassistant/components/flick_electric/ @ZephireNZ
/tests/components/flick_electric/ @ZephireNZ
/homeassistant/components/flipr/ @cnico /homeassistant/components/flipr/ @cnico
/tests/components/flipr/ @cnico /tests/components/flipr/ @cnico
/homeassistant/components/flo/ @dmulcahey /homeassistant/components/flo/ @dmulcahey
@@ -619,8 +615,6 @@ build.json @home-assistant/supervisor
/tests/components/greeneye_monitor/ @jkeljo /tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core /homeassistant/components/group/ @home-assistant/core
/tests/components/group/ @home-assistant/core /tests/components/group/ @home-assistant/core
/homeassistant/components/growatt_server/ @johanzander
/tests/components/growatt_server/ @johanzander
/homeassistant/components/guardian/ @bachya /homeassistant/components/guardian/ @bachya
/tests/components/guardian/ @bachya /tests/components/guardian/ @bachya
/homeassistant/components/habitica/ @tr4nt0r /homeassistant/components/habitica/ @tr4nt0r
@@ -741,8 +735,6 @@ build.json @home-assistant/supervisor
/tests/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh /homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh /tests/components/incomfort/ @jbouwh
/homeassistant/components/inels/ @epdevlab
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01 /homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01
/homeassistant/components/inkbird/ @bdraco /homeassistant/components/inkbird/ @bdraco
@@ -768,8 +760,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intesishome/ @jnimmo /homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs /homeassistant/components/iometer/ @MaestroOnICe
/tests/components/iometer/ @jukrebs /tests/components/iometer/ @MaestroOnICe
/homeassistant/components/ios/ @robbiet480 /homeassistant/components/ios/ @robbiet480
/tests/components/ios/ @robbiet480 /tests/components/ios/ @robbiet480
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard /homeassistant/components/iotawatt/ @gtdiehl @jyavenard
@@ -916,8 +908,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/luci/ @mzdrale /homeassistant/components/luci/ @mzdrale
/homeassistant/components/luftdaten/ @fabaff @frenck /homeassistant/components/luftdaten/ @fabaff @frenck
/tests/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck
/homeassistant/components/lunatone/ @MoonDevLT
/tests/components/lunatone/ @MoonDevLT
/homeassistant/components/lupusec/ @majuss @suaveolent /homeassistant/components/lupusec/ @majuss @suaveolent
/tests/components/lupusec/ @majuss @suaveolent /tests/components/lupusec/ @majuss @suaveolent
/homeassistant/components/lutron/ @cdheiser @wilburCForce /homeassistant/components/lutron/ @cdheiser @wilburCForce
@@ -963,8 +953,6 @@ build.json @home-assistant/supervisor
/tests/components/met_eireann/ @DylanGore /tests/components/met_eireann/ @DylanGore
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/homeassistant/components/meteo_lt/ @xE1H
/tests/components/meteo_lt/ @xE1H
/homeassistant/components/meteoalarm/ @rolfberkenbosch /homeassistant/components/meteoalarm/ @rolfberkenbosch
/homeassistant/components/meteoclimatic/ @adrianmo /homeassistant/components/meteoclimatic/ @adrianmo
/tests/components/meteoclimatic/ @adrianmo /tests/components/meteoclimatic/ @adrianmo
@@ -1071,8 +1059,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nilu/ @hfurubotten
/homeassistant/components/nina/ @DeerMaximum /homeassistant/components/nina/ @DeerMaximum
/tests/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum
/homeassistant/components/nintendo_parental_controls/ @pantherale0
/tests/components/nintendo_parental_controls/ @pantherale0
/homeassistant/components/nissan_leaf/ @filcole /homeassistant/components/nissan_leaf/ @filcole
/homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
@@ -1141,8 +1127,6 @@ build.json @home-assistant/supervisor
/tests/components/opengarage/ @danielhiversen /tests/components/opengarage/ @danielhiversen
/homeassistant/components/openhome/ @bazwilliams /homeassistant/components/openhome/ @bazwilliams
/tests/components/openhome/ @bazwilliams /tests/components/openhome/ @bazwilliams
/homeassistant/components/openrgb/ @felipecrs
/tests/components/openrgb/ @felipecrs
/homeassistant/components/opensky/ @joostlek /homeassistant/components/opensky/ @joostlek
/tests/components/opensky/ @joostlek /tests/components/opensky/ @joostlek
/homeassistant/components/opentherm_gw/ @mvn23 /homeassistant/components/opentherm_gw/ @mvn23
@@ -1206,6 +1190,8 @@ build.json @home-assistant/supervisor
/tests/components/plex/ @jjlawren /tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew /homeassistant/components/plugwise/ @CoMPaTech @bouwew
/tests/components/plugwise/ @CoMPaTech @bouwew /tests/components/plugwise/ @CoMPaTech @bouwew
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
/homeassistant/components/point/ @fredrike /homeassistant/components/point/ @fredrike
/tests/components/point/ @fredrike /tests/components/point/ @fredrike
/homeassistant/components/pooldose/ @lmaertin /homeassistant/components/pooldose/ @lmaertin
@@ -1421,8 +1407,8 @@ build.json @home-assistant/supervisor
/tests/components/sfr_box/ @epenet /tests/components/sfr_box/ @epenet
/homeassistant/components/sftp_storage/ @maretodoric /homeassistant/components/sftp_storage/ @maretodoric
/tests/components/sftp_storage/ @maretodoric /tests/components/sftp_storage/ @maretodoric
/homeassistant/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre /homeassistant/components/sharkiq/ @JeffResc @funkybunch
/tests/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre /tests/components/sharkiq/ @JeffResc @funkybunch
/homeassistant/components/shell_command/ @home-assistant/core /homeassistant/components/shell_command/ @home-assistant/core
/tests/components/shell_command/ @home-assistant/core /tests/components/shell_command/ @home-assistant/core
/homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco /homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco
@@ -1477,6 +1463,8 @@ build.json @home-assistant/supervisor
/tests/components/smhi/ @gjohansson-ST /tests/components/smhi/ @gjohansson-ST
/homeassistant/components/smlight/ @tl-sl /homeassistant/components/smlight/ @tl-sl
/tests/components/smlight/ @tl-sl /tests/components/smlight/ @tl-sl
/homeassistant/components/sms/ @ocalvo
/tests/components/sms/ @ocalvo
/homeassistant/components/snapcast/ @luar123 /homeassistant/components/snapcast/ @luar123
/tests/components/snapcast/ @luar123 /tests/components/snapcast/ @luar123
/homeassistant/components/snmp/ @nmaggioni /homeassistant/components/snmp/ @nmaggioni
@@ -1485,8 +1473,8 @@ build.json @home-assistant/supervisor
/tests/components/snoo/ @Lash-L /tests/components/snoo/ @Lash-L
/homeassistant/components/snooz/ @AustinBrunkhorst /homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst
/homeassistant/components/solaredge/ @frenck @bdraco @tronikos /homeassistant/components/solaredge/ @frenck @bdraco
/tests/components/solaredge/ @frenck @bdraco @tronikos /tests/components/solaredge/ @frenck @bdraco
/homeassistant/components/solaredge_local/ @drobtravels @scheric /homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79 @dontinelli /homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli /tests/components/solarlog/ @Ernst79 @dontinelli
@@ -1539,8 +1527,6 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2 /tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @home-assistant/core /homeassistant/components/sun/ @home-assistant/core
/tests/components/sun/ @home-assistant/core /tests/components/sun/ @home-assistant/core
/homeassistant/components/sunricher_dali/ @niracler
/tests/components/sunricher_dali/ @niracler
/homeassistant/components/supla/ @mwegrzynek /homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen /homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen
@@ -1717,8 +1703,8 @@ build.json @home-assistant/supervisor
/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04 /tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
/homeassistant/components/valve/ @home-assistant/core /homeassistant/components/valve/ @home-assistant/core
/tests/components/valve/ @home-assistant/core /tests/components/valve/ @home-assistant/core
/homeassistant/components/vegehub/ @thulrus /homeassistant/components/vegehub/ @ghowevege
/tests/components/vegehub/ @thulrus /tests/components/vegehub/ @ghowevege
/homeassistant/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew /homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
@@ -1817,8 +1803,8 @@ build.json @home-assistant/supervisor
/tests/components/ws66i/ @ssaenger /tests/components/ws66i/ @ssaenger
/homeassistant/components/wyoming/ @synesthesiam /homeassistant/components/wyoming/ @synesthesiam
/tests/components/wyoming/ @synesthesiam /tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @hunterjm @tr4nt0r /homeassistant/components/xbox/ @hunterjm
/tests/components/xbox/ @hunterjm @tr4nt0r /tests/components/xbox/ @hunterjm
/homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi
/tests/components/xiaomi_aqara/ @danielhiversen @syssi /tests/components/xiaomi_aqara/ @danielhiversen @syssi
/homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79

4
Dockerfile generated
View File

@@ -25,13 +25,13 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \ "armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \ esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \ && chmod +x /bin/go2rtc \
# Verify go2rtc can be executed # Verify go2rtc can be executed
&& go2rtc --version && go2rtc --version
# Install uv # Install uv
RUN pip3 install uv==0.9.6 RUN pip3 install uv==0.8.9
WORKDIR /usr/src WORKDIR /usr/src

View File

@@ -13,6 +13,7 @@ RUN \
libavcodec-dev \ libavcodec-dev \
libavdevice-dev \ libavdevice-dev \
libavutil-dev \ libavutil-dev \
libgammu-dev \
libswscale-dev \ libswscale-dev \
libswresample-dev \ libswresample-dev \
libavfilter-dev \ libavfilter-dev \
@@ -33,11 +34,9 @@ WORKDIR /usr/src
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN uv python install 3.13.2
USER vscode USER vscode
COPY .python-version ./
RUN uv python install
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
RUN uv venv $VIRTUAL_ENV RUN uv venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH" ENV PATH="$VIRTUAL_ENV/bin:$PATH"

View File

@@ -1,10 +1,13 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.2
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.2
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.2
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.2
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1 i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.2
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
cosign: cosign:
base_identity: https://github.com/home-assistant/docker/.* base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.* identity: https://github.com/home-assistant/core/.*

View File

@@ -6,6 +6,7 @@ Sending HOTP through notify service
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict
import logging import logging
from typing import Any, cast from typing import Any, cast
@@ -303,14 +304,13 @@ class NotifySetupFlow(SetupFlow[NotifyAuthModule]):
if not self._available_notify_services: if not self._available_notify_services:
return self.async_abort(reason="no_available_service") return self.async_abort(reason="no_available_service")
schema = vol.Schema( schema: dict[str, Any] = OrderedDict()
{ schema["notify_service"] = vol.In(self._available_notify_services)
vol.Required("notify_service"): vol.In(self._available_notify_services), schema["target"] = vol.Optional(str)
vol.Optional("target"): str,
}
)
return self.async_show_form(step_id="init", data_schema=schema, errors=errors) return self.async_show_form(
step_id="init", data_schema=vol.Schema(schema), errors=errors
)
async def async_step_setup( async def async_step_setup(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None

View File

@@ -34,9 +34,6 @@ INPUT_FIELD_CODE = "code"
DUMMY_SECRET = "FPPTH34D4E3MI2HG" DUMMY_SECRET = "FPPTH34D4E3MI2HG"
GOOGLE_AUTHENTICATOR_URL = "https://support.google.com/accounts/answer/1066447"
AUTHY_URL = "https://authy.com/"
def _generate_qr_code(data: str) -> str: def _generate_qr_code(data: str) -> str:
"""Generate a base64 PNG string represent QR Code image of data.""" """Generate a base64 PNG string represent QR Code image of data."""
@@ -232,8 +229,6 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
"code": self._ota_secret, "code": self._ota_secret,
"url": self._url, "url": self._url,
"qr_code": self._image, "qr_code": self._image,
"google_authenticator_url": GOOGLE_AUTHENTICATOR_URL,
"authy_url": AUTHY_URL,
}, },
errors=errors, errors=errors,
) )

View File

@@ -179,18 +179,12 @@ class Data:
user_hash = base64.b64decode(found["password"]) user_hash = base64.b64decode(found["password"])
# bcrypt.checkpw is timing-safe # bcrypt.checkpw is timing-safe
# With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError. if not bcrypt.checkpw(password.encode(), user_hash):
# Previously the password was silently truncated.
# https://github.com/pyca/bcrypt/pull/1000
if not bcrypt.checkpw(password.encode()[:72], user_hash):
raise InvalidAuth raise InvalidAuth
def hash_password(self, password: str, for_storage: bool = False) -> bytes: def hash_password(self, password: str, for_storage: bool = False) -> bytes:
"""Encode a password.""" """Encode a password."""
# With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError. hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
# Previously the password was silently truncated.
# https://github.com/pyca/bcrypt/pull/1000
hashed: bytes = bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt(rounds=12))
if for_storage: if for_storage:
hashed = base64.b64encode(hashed) hashed = base64.b64encode(hashed)

View File

@@ -616,34 +616,34 @@ async def async_enable_logging(
), ),
) )
logger = logging.getLogger() # Log errors to a file if we have write access to file or config dir
logger.setLevel(logging.INFO if verbose else logging.WARNING)
if log_file is None: if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME) err_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
err_log_path = None
else:
err_log_path = default_log_path
else: else:
err_log_path = os.path.abspath(log_file) err_log_path = os.path.abspath(log_file)
if err_log_path: err_path_exists = os.path.isfile(err_log_path)
err_dir = os.path.dirname(err_log_path)
# Check if we can write to the error log if it exists or that
# we can create files in the containing directory if not.
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)
):
err_handler = await hass.async_add_executor_job( err_handler = await hass.async_add_executor_job(
_create_log_file, err_log_path, log_rotate_days _create_log_file, err_log_path, log_rotate_days
) )
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
logger = logging.getLogger()
logger.addHandler(err_handler) logger.addHandler(err_handler)
logger.setLevel(logging.INFO if verbose else logging.WARNING)
# Save the log file location for access by other components. # Save the log file location for access by other components.
hass.data[DATA_LOGGING] = err_log_path hass.data[DATA_LOGGING] = err_log_path
else:
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
async_activate_log_queue_handler(hass) async_activate_log_queue_handler(hass)

View File

@@ -1,5 +0,0 @@
{
"domain": "eltako",
"name": "Eltako",
"iot_standards": ["matter"]
}

View File

@@ -0,0 +1,5 @@
{
"domain": "ibm",
"name": "IBM",
"integrations": ["watson_iot", "watson_tts"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "konnected",
"name": "Konnected",
"integrations": ["konnected", "konnected_esphome"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "level",
"name": "Level",
"iot_standards": ["matter"]
}

View File

@@ -1,5 +1,11 @@
{ {
"domain": "yale", "domain": "yale",
"name": "Yale (non-US/Canada)", "name": "Yale",
"integrations": ["yale", "yalexs_ble", "yale_smart_alarm"] "integrations": [
"august",
"yale_smart_alarm",
"yalexs_ble",
"yale_home",
"yale"
]
} }

View File

@@ -1,5 +0,0 @@
{
"domain": "yale_august",
"name": "Yale August (US/Canada)",
"integrations": ["august", "august_ble"]
}

View File

@@ -1,70 +1,70 @@
{ {
"config": { "config": {
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_mfa_code": "Invalid MFA code"
},
"step": { "step": {
"user": {
"title": "Fill in your Abode login information",
"data": {
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"mfa": { "mfa": {
"title": "Enter your MFA code for Abode",
"data": { "data": {
"mfa_code": "MFA code (6-digits)" "mfa_code": "MFA code (6-digits)"
}, }
"title": "Enter your MFA code for Abode"
}, },
"reauth_confirm": { "reauth_confirm": {
"title": "[%key:component::abode::config::step::user::title%]",
"data": { "data": {
"password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::email%]",
"username": "[%key:common::config_flow::data::email%]" "password": "[%key:common::config_flow::data::password%]"
}, }
"title": "[%key:component::abode::config::step::user::title%]"
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::email%]"
},
"title": "Fill in your Abode login information"
} }
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_mfa_code": "Invalid MFA code"
},
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
}, },
"services": { "services": {
"capture_image": { "capture_image": {
"name": "Capture image",
"description": "Requests a new image capture from a camera device.", "description": "Requests a new image capture from a camera device.",
"fields": { "fields": {
"entity_id": { "entity_id": {
"description": "Entity ID of the camera to request an image from.", "name": "Entity",
"name": "Entity" "description": "Entity ID of the camera to request an image from."
} }
}, }
"name": "Capture image"
}, },
"change_setting": { "change_setting": {
"name": "Change setting",
"description": "Changes an Abode system setting.", "description": "Changes an Abode system setting.",
"fields": { "fields": {
"setting": { "setting": {
"description": "Setting to change.", "name": "Setting",
"name": "Setting" "description": "Setting to change."
}, },
"value": { "value": {
"description": "Value of the setting.", "name": "Value",
"name": "Value" "description": "Value of the setting."
} }
}, }
"name": "Change setting"
}, },
"trigger_automation": { "trigger_automation": {
"name": "Trigger automation",
"description": "Triggers an Abode automation.", "description": "Triggers an Abode automation.",
"fields": { "fields": {
"entity_id": { "entity_id": {
"description": "Entity ID of the automation to trigger.", "name": "Entity",
"name": "Entity" "description": "Entity ID of the automation to trigger."
} }
}, }
"name": "Trigger automation"
} }
} }
} }

View File

@@ -12,13 +12,11 @@ from homeassistant.components.bluetooth import async_get_scanner
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_IS_NEW_STYLE_SCALE from .const import CONF_IS_NEW_STYLE_SCALE
SCAN_INTERVAL = timedelta(seconds=15) SCAN_INTERVAL = timedelta(seconds=15)
UPDATE_DEBOUNCE_TIME = 0.2
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -40,19 +38,11 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
config_entry=entry, config_entry=entry,
) )
debouncer = Debouncer(
hass=hass,
logger=_LOGGER,
cooldown=UPDATE_DEBOUNCE_TIME,
immediate=True,
function=self.async_update_listeners,
)
self._scale = AcaiaScale( self._scale = AcaiaScale(
address_or_ble_device=entry.data[CONF_ADDRESS], address_or_ble_device=entry.data[CONF_ADDRESS],
name=entry.title, name=entry.title,
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE], is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
notify_callback=debouncer.async_schedule_call, notify_callback=self.async_update_listeners,
scanner=async_get_scanner(hass), scanner=async_get_scanner(hass),
) )

View File

@@ -4,20 +4,20 @@
"timer_running": { "timer_running": {
"default": "mdi:timer", "default": "mdi:timer",
"state": { "state": {
"off": "mdi:timer-off", "on": "mdi:timer-play",
"on": "mdi:timer-play" "off": "mdi:timer-off"
} }
} }
}, },
"button": { "button": {
"tare": {
"default": "mdi:scale-balance"
},
"reset_timer": { "reset_timer": {
"default": "mdi:timer-refresh" "default": "mdi:timer-refresh"
}, },
"start_stop": { "start_stop": {
"default": "mdi:timer-play" "default": "mdi:timer-play"
},
"tare": {
"default": "mdi:scale-balance"
} }
} }
} }

View File

@@ -1,5 +1,6 @@
{ {
"config": { "config": {
"flow_title": "{name}",
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
@@ -9,19 +10,18 @@
"device_not_found": "Device could not be found.", "device_not_found": "Device could not be found.",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"flow_title": "{name}",
"step": { "step": {
"bluetooth_confirm": { "bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
}, },
"user": { "user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": { "data": {
"address": "[%key:common::config_flow::data::device%]" "address": "[%key:common::config_flow::data::device%]"
}, },
"data_description": { "data_description": {
"address": "Select Acaia scale you want to set up" "address": "Select Acaia scale you want to set up"
}, }
"description": "[%key:component::bluetooth::config::step::user::description%]"
} }
} }
}, },
@@ -32,14 +32,14 @@
} }
}, },
"button": { "button": {
"tare": {
"name": "Tare"
},
"reset_timer": { "reset_timer": {
"name": "Reset timer" "name": "Reset timer"
}, },
"start_stop": { "start_stop": {
"name": "Start/stop timer" "name": "Start/stop timer"
},
"tare": {
"name": "Tare"
} }
} }
} }

View File

@@ -71,4 +71,4 @@ POLLEN_CATEGORY_MAP = {
} }
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10) UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(minutes=30) UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30)

View File

@@ -1,9 +1,6 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"air_quality": {
"default": "mdi:air-filter"
},
"cloud_ceiling": { "cloud_ceiling": {
"default": "mdi:weather-fog" "default": "mdi:weather-fog"
}, },
@@ -37,6 +34,9 @@
"thunderstorm_probability_night": { "thunderstorm_probability_night": {
"default": "mdi:weather-lightning" "default": "mdi:weather-lightning"
}, },
"translation_key": {
"default": "mdi:air-filter"
},
"tree_pollen": { "tree_pollen": {
"default": "mdi:tree-outline" "default": "mdi:tree-outline"
}, },

View File

@@ -1,8 +1,25 @@
{ {
"config": { "config": {
"abort": { "step": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]", "user": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" "data": {
"name": "[%key:common::config_flow::data::name%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]"
},
"data_description": {
"api_key": "API key generated in the AccuWeather APIs portal."
}
},
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::accuweather::config::step::user::data_description::api_key%]"
}
}
}, },
"create_entry": { "create_entry": {
"default": "Some sensors are not enabled by default. You can enable them in the entity registry after the integration configuration." "default": "Some sensors are not enabled by default. You can enable them in the entity registry after the integration configuration."
@@ -12,26 +29,9 @@
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key." "requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key."
}, },
"step": { "abort": {
"reauth_confirm": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"data": { "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::accuweather::config::step::user::data_description::api_key%]"
}
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"api_key": "API key generated in the AccuWeather APIs portal."
}
}
} }
}, },
"entity": { "entity": {
@@ -120,9 +120,9 @@
"pressure_tendency": { "pressure_tendency": {
"name": "Pressure tendency", "name": "Pressure tendency",
"state": { "state": {
"falling": "Falling", "steady": "Steady",
"rising": "Rising", "rising": "Rising",
"steady": "Steady" "falling": "Falling"
}, },
"state_attributes": { "state_attributes": {
"options": { "options": {
@@ -227,6 +227,9 @@
"wet_bulb_temperature": { "wet_bulb_temperature": {
"name": "Wet bulb temperature" "name": "Wet bulb temperature"
}, },
"wind_speed": {
"name": "[%key:component::weather::entity_component::_::state_attributes::wind_speed::name%]"
},
"wind_chill_temperature": { "wind_chill_temperature": {
"name": "Wind chill temperature" "name": "Wind chill temperature"
}, },
@@ -239,9 +242,6 @@
"wind_gust_speed_night": { "wind_gust_speed_night": {
"name": "Wind gust speed night {forecast_day}" "name": "Wind gust speed night {forecast_day}"
}, },
"wind_speed": {
"name": "[%key:component::weather::entity_component::_::state_attributes::wind_speed::name%]"
},
"wind_speed_day": { "wind_speed_day": {
"name": "Wind speed day {forecast_day}" "name": "Wind speed day {forecast_day}"
}, },

View File

@@ -1,15 +1,15 @@
{ {
"config": { "config": {
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"step": { "step": {
"user": { "user": {
"title": "Pick a hub to add",
"data": { "data": {
"id": "Host ID" "id": "Host ID"
}, }
"title": "Pick a hub to add"
} }
},
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
} }
} }
} }

View File

@@ -1,57 +0,0 @@
"""The Actron Air integration."""
from actron_neo_api import (
ActronAirNeoACSystem,
ActronNeoAPI,
ActronNeoAPIError,
ActronNeoAuthError,
)
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from .const import _LOGGER
from .coordinator import (
ActronAirConfigEntry,
ActronAirRuntimeData,
ActronAirSystemCoordinator,
)
PLATFORM = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Set up Actron Air integration from a config entry."""
api = ActronNeoAPI(refresh_token=entry.data[CONF_API_TOKEN])
systems: list[ActronAirNeoACSystem] = []
try:
systems = await api.get_ac_systems()
await api.update_status()
except ActronNeoAuthError:
_LOGGER.error("Authentication error while setting up Actron Air integration")
raise
except ActronNeoAPIError as err:
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
raise
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
coordinator = ActronAirSystemCoordinator(hass, entry, api, system)
_LOGGER.debug("Setting up coordinator for system: %s", system["serial"])
await coordinator.async_config_entry_first_refresh()
system_coordinators[system["serial"]] = coordinator
entry.runtime_data = ActronAirRuntimeData(
api=api,
system_coordinators=system_coordinators,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORM)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORM)

View File

@@ -1,259 +0,0 @@
"""Climate platform for Actron Air integration."""
from typing import Any
from actron_neo_api import ActronAirNeoStatus, ActronAirNeoZone
from homeassistant.components.climate import (
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
PARALLEL_UPDATES = 0
FAN_MODE_MAPPING_ACTRONAIR_TO_HA = {
"AUTO": FAN_AUTO,
"LOW": FAN_LOW,
"MED": FAN_MEDIUM,
"HIGH": FAN_HIGH,
}
FAN_MODE_MAPPING_HA_TO_ACTRONAIR = {
v: k for k, v in FAN_MODE_MAPPING_ACTRONAIR_TO_HA.items()
}
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA = {
"COOL": HVACMode.COOL,
"HEAT": HVACMode.HEAT,
"FAN": HVACMode.FAN_ONLY,
"AUTO": HVACMode.AUTO,
"OFF": HVACMode.OFF,
}
HVAC_MODE_MAPPING_HA_TO_ACTRONAIR = {
v: k for k, v in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.items()
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air climate entities."""
system_coordinators = entry.runtime_data.system_coordinators
entities: list[ClimateEntity] = []
for coordinator in system_coordinators.values():
status = coordinator.data
name = status.ac_system.system_name
entities.append(ActronSystemClimate(coordinator, name))
entities.extend(
ActronZoneClimate(coordinator, zone)
for zone in status.remote_zone_info
if zone.exists
)
async_add_entities(entities)
class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEntity):
"""Base class for Actron Air climate entities."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_attr_name = None
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator)
self._serial_number = coordinator.serial_number
class ActronSystemClimate(BaseClimateEntity):
"""Representation of the Actron Air system."""
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator, name)
serial_number = coordinator.serial_number
self._attr_unique_id = serial_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=self._status.ac_system.system_name,
manufacturer="Actron Air",
model_id=self._status.ac_system.master_wc_model,
sw_version=self._status.ac_system.master_wc_firmware_version,
serial_number=serial_number,
)
@property
def min_temp(self) -> float:
"""Return the minimum temperature that can be set."""
return self._status.min_temp
@property
def max_temp(self) -> float:
"""Return the maximum temperature that can be set."""
return self._status.max_temp
@property
def _status(self) -> ActronAirNeoStatus:
"""Get the current status from the coordinator."""
return self.coordinator.data
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
if not self._status.user_aircon_settings.is_on:
return HVACMode.OFF
mode = self._status.user_aircon_settings.mode
return HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.get(mode)
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_mode = self._status.user_aircon_settings.fan_mode
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
@property
def current_humidity(self) -> float:
"""Return the current humidity."""
return self._status.master_info.live_humidity_pc
@property
def current_temperature(self) -> float:
"""Return the current temperature."""
return self._status.master_info.live_temp_c
@property
def target_temperature(self) -> float:
"""Return the target temperature."""
return self._status.user_aircon_settings.temperature_setpoint_cool_c
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode.lower())
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
await self._status.user_aircon_settings.set_temperature(temperature=temp)
class ActronZoneClimate(BaseClimateEntity):
"""Representation of a zone within the Actron Air system."""
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
zone: ActronAirNeoZone,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator, zone.title)
serial_number = coordinator.serial_number
self._zone_id: int = zone.zone_id
self._attr_unique_id: str = f"{serial_number}_zone_{zone.zone_id}"
self._attr_device_info: DeviceInfo = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=zone.title,
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, serial_number),
)
@property
def min_temp(self) -> float:
"""Return the minimum temperature that can be set."""
return self._zone.min_temp
@property
def max_temp(self) -> float:
"""Return the maximum temperature that can be set."""
return self._zone.max_temp
@property
def _zone(self) -> ActronAirNeoZone:
"""Get the current zone data from the coordinator."""
status = self.coordinator.data
return status.zones[self._zone_id]
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
if self._zone.is_active:
mode = self._zone.hvac_mode
return HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.get(mode)
return HVACMode.OFF
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""
return self._zone.humidity
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._zone.live_temp_c
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
return self._zone.temperature_setpoint_cool_c
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
is_enabled = hvac_mode != HVACMode.OFF
await self._zone.enable(is_enabled)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
await self._zone.set_temperature(temperature=kwargs["temperature"])

View File

@@ -1,132 +0,0 @@
"""Setup config flow for Actron Air integration."""
import asyncio
from typing import Any
from actron_neo_api import ActronNeoAPI, ActronNeoAuthError
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
from .const import _LOGGER, DOMAIN
class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Actron Air."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._api: ActronNeoAPI | None = None
self._device_code: str | None = None
self._user_code: str = ""
self._verification_uri: str = ""
self._expires_minutes: str = "30"
self.login_task: asyncio.Task | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if self._api is None:
_LOGGER.debug("Initiating device authorization")
self._api = ActronNeoAPI()
try:
device_code_response = await self._api.request_device_code()
except ActronNeoAuthError as err:
_LOGGER.error("OAuth2 flow failed: %s", err)
return self.async_abort(reason="oauth2_error")
self._device_code = device_code_response["device_code"]
self._user_code = device_code_response["user_code"]
self._verification_uri = device_code_response["verification_uri_complete"]
self._expires_minutes = str(device_code_response["expires_in"] // 60)
async def _wait_for_authorization() -> None:
"""Wait for the user to authorize the device."""
assert self._api is not None
assert self._device_code is not None
_LOGGER.debug("Waiting for device authorization")
try:
await self._api.poll_for_token(self._device_code)
_LOGGER.debug("Authorization successful")
except ActronNeoAuthError as ex:
_LOGGER.exception("Error while waiting for device authorization")
raise CannotConnect from ex
_LOGGER.debug("Checking login task")
if self.login_task is None:
_LOGGER.debug("Creating task for device authorization")
self.login_task = self.hass.async_create_task(_wait_for_authorization())
if self.login_task.done():
_LOGGER.debug("Login task is done, checking results")
if exception := self.login_task.exception():
if isinstance(exception, CannotConnect):
return self.async_show_progress_done(
next_step_id="connection_error"
)
return self.async_show_progress_done(next_step_id="timeout")
return self.async_show_progress_done(next_step_id="finish_login")
return self.async_show_progress(
step_id="user",
progress_action="wait_for_authorization",
description_placeholders={
"user_code": self._user_code,
"verification_uri": self._verification_uri,
"expires_minutes": self._expires_minutes,
},
progress_task=self.login_task,
)
async def async_step_finish_login(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the finalization of login."""
_LOGGER.debug("Finalizing authorization")
assert self._api is not None
try:
user_data = await self._api.get_user_info()
except ActronNeoAuthError as err:
_LOGGER.error("Error getting user info: %s", err)
return self.async_abort(reason="oauth2_error")
unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value},
)
async def async_step_timeout(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle issues that need transition await from progress step."""
if user_input is None:
return self.async_show_form(
step_id="timeout",
)
del self.login_task
return await self.async_step_user()
async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle connection error from progress step."""
if user_input is None:
return self.async_show_form(step_id="connection_error")
# Reset state and try again
self._api = None
self._device_code = None
self.login_task = None
return await self.async_step_user()
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@@ -1,6 +0,0 @@
"""Constants used by Actron Air integration."""
import logging
_LOGGER = logging.getLogger(__package__)
DOMAIN = "actron_air"

View File

@@ -1,69 +0,0 @@
"""Coordinator for Actron Air integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from actron_neo_api import ActronAirNeoACSystem, ActronAirNeoStatus, ActronNeoAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import _LOGGER
STALE_DEVICE_TIMEOUT = timedelta(hours=24)
ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
ERROR_UNKNOWN = "unknown_error"
@dataclass
class ActronAirRuntimeData:
"""Runtime data for the Actron Air integration."""
api: ActronNeoAPI
system_coordinators: dict[str, ActronAirSystemCoordinator]
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
AUTH_ERROR_THRESHOLD = 3
SCAN_INTERVAL = timedelta(seconds=30)
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirNeoACSystem]):
"""System coordinator for Actron Air integration."""
def __init__(
self,
hass: HomeAssistant,
entry: ActronAirConfigEntry,
api: ActronNeoAPI,
system: ActronAirNeoACSystem,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name="Actron Air Status",
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
self.system = system
self.serial_number = system["serial"]
self.api = api
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
async def _async_update_data(self) -> ActronAirNeoStatus:
"""Fetch updates and merge incremental changes into the full state."""
await self.api.update_status()
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
return self.status
def is_device_stale(self) -> bool:
"""Check if a device is stale (not seen for a while)."""
return (dt_util.utcnow() - self.last_seen) > STALE_DEVICE_TIMEOUT

View File

@@ -1,16 +0,0 @@
{
"domain": "actron_air",
"name": "Actron Air",
"codeowners": ["@kclif9", "@JagadishDhanamjayam"],
"config_flow": true,
"dhcp": [
{
"hostname": "neo-*",
"macaddress": "FC0FE7*"
}
],
"documentation": "https://www.home-assistant.io/integrations/actron_air",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["actron-neo-api==0.1.84"]
}

View File

@@ -1,78 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not have custom service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not have custom service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not subscribe to external events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options flow
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: This integration uses DHCP discovery, however is cloud polling. Therefore there is no information to update.
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category:
status: exempt
comment: This integration does not use entity categories.
entity-device-class:
status: exempt
comment: This integration does not use entity device classes.
entity-disabled-by-default:
status: exempt
comment: Not required for this integration at this stage.
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration does not have any known issues that require repair.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo

View File

@@ -1,29 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"oauth2_error": "Failed to start OAuth2 flow"
},
"error": {
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
},
"progress": {
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
},
"step": {
"connection_error": {
"data": {},
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
"title": "Connection error"
},
"timeout": {
"data": {},
"description": "The authorization process timed out. Please try again.",
"title": "Authorization timeout"
},
"user": {
"title": "Actron Air OAuth2 Authorization"
}
}
}
}

View File

@@ -17,11 +17,6 @@ from homeassistant.const import (
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
) )
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import ( from .const import (
ACCOUNT_ID, ACCOUNT_ID,
@@ -71,15 +66,7 @@ class AdaxConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the local step.""" """Handle the local step."""
data_schema = vol.Schema( data_schema = vol.Schema(
{ {vol.Required(WIFI_SSID): str, vol.Required(WIFI_PSWD): str}
vol.Required(WIFI_SSID): str,
vol.Required(WIFI_PSWD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
),
),
}
) )
if user_input is None: if user_input is None:
return self.async_show_form( return self.async_show_form(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/adax", "documentation": "https://www.home-assistant.io/integrations/adax",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["adax", "adax_local"], "loggers": ["adax", "adax_local"],
"requirements": ["adax==0.4.0", "Adax-local==0.2.0"] "requirements": ["adax==0.4.0", "Adax-local==0.1.5"]
} }

View File

@@ -2,16 +2,14 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import cast from typing import cast
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import UnitOfEnergy, UnitOfTemperature from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -22,74 +20,44 @@ from .const import CONNECTION_TYPE, DOMAIN, LOCAL
from .coordinator import AdaxCloudCoordinator from .coordinator import AdaxCloudCoordinator
@dataclass(kw_only=True, frozen=True)
class AdaxSensorDescription(SensorEntityDescription):
"""Describes Adax sensor entity."""
data_key: str
SENSORS: tuple[AdaxSensorDescription, ...] = (
AdaxSensorDescription(
key="temperature",
data_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
AdaxSensorDescription(
key="energy",
data_key="energyWh",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=3,
),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: AdaxConfigEntry, entry: AdaxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the Adax sensors with config flow.""" """Set up the Adax energy sensors with config flow."""
if entry.data.get(CONNECTION_TYPE) != LOCAL: if entry.data.get(CONNECTION_TYPE) != LOCAL:
cloud_coordinator = cast(AdaxCloudCoordinator, entry.runtime_data) cloud_coordinator = cast(AdaxCloudCoordinator, entry.runtime_data)
# Create individual energy sensors for each device # Create individual energy sensors for each device
async_add_entities( async_add_entities(
[ AdaxEnergySensor(cloud_coordinator, device_id)
AdaxSensor(cloud_coordinator, entity_description, device_id) for device_id in cloud_coordinator.data
for device_id in cloud_coordinator.data
for entity_description in SENSORS
]
) )
class AdaxSensor(CoordinatorEntity[AdaxCloudCoordinator], SensorEntity): class AdaxEnergySensor(CoordinatorEntity[AdaxCloudCoordinator], SensorEntity):
"""Representation of an Adax sensor.""" """Representation of an Adax energy sensor."""
entity_description: AdaxSensorDescription
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_translation_key = "energy"
_attr_device_class = SensorDeviceClass.ENERGY
_attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR
_attr_suggested_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
_attr_state_class = SensorStateClass.TOTAL_INCREASING
_attr_suggested_display_precision = 3
def __init__( def __init__(
self, self,
coordinator: AdaxCloudCoordinator, coordinator: AdaxCloudCoordinator,
entity_description: AdaxSensorDescription,
device_id: str, device_id: str,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the energy sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = entity_description
self._device_id = device_id self._device_id = device_id
room = coordinator.data[device_id] room = coordinator.data[device_id]
self._attr_unique_id = ( self._attr_unique_id = f"{room['homeId']}_{device_id}_energy"
f"{room['homeId']}_{device_id}_{self.entity_description.key}"
)
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)}, identifiers={(DOMAIN, device_id)},
name=room["name"], name=room["name"],
@@ -100,14 +68,10 @@ class AdaxSensor(CoordinatorEntity[AdaxCloudCoordinator], SensorEntity):
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
return ( return (
super().available super().available and "energyWh" in self.coordinator.data[self._device_id]
and self.entity_description.data_key
in self.coordinator.data[self._device_id]
) )
@property @property
def native_value(self) -> int | float | None: def native_value(self) -> int:
"""Return the native value of the sensor.""" """Return the native value of the sensor."""
return self.coordinator.data[self._device_id].get( return int(self.coordinator.data[self._device_id]["energyWh"])
self.entity_description.data_key
)

View File

@@ -1,34 +1,34 @@
{ {
"config": { "config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"heater_not_available": "Heater not available. Try to reset the heater by pressing + and OK for some seconds.",
"heater_not_found": "Heater not found. Try to move the heater closer to Home Assistant computer.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": { "step": {
"cloud": {
"data": {
"account_id": "Account ID",
"password": "[%key:common::config_flow::data::password%]"
}
},
"local": {
"data": {
"wifi_pswd": "Wi-Fi password",
"wifi_ssid": "Wi-Fi SSID"
},
"description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue LED starts blinking before pressing Submit. Configuring heater might take some minutes."
},
"user": { "user": {
"data": { "data": {
"connection_type": "Select connection type" "connection_type": "Select connection type"
}, },
"description": "Select connection type. Local requires heaters with Bluetooth" "description": "Select connection type. Local requires heaters with Bluetooth"
},
"local": {
"data": {
"wifi_ssid": "Wi-Fi SSID",
"wifi_pswd": "Wi-Fi password"
},
"description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue LED starts blinking before pressing Submit. Configuring heater might take some minutes."
},
"cloud": {
"data": {
"account_id": "Account ID",
"password": "[%key:common::config_flow::data::password%]"
}
} }
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"heater_not_available": "Heater not available. Try to reset the heater by pressing + and OK for some seconds.",
"heater_not_found": "Heater not found. Try to move the heater closer to Home Assistant computer.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
} }
} }
} }

View File

@@ -1,9 +1,6 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"average_processing_speed": {
"default": "mdi:speedometer"
},
"dns_queries": { "dns_queries": {
"default": "mdi:magnify" "default": "mdi:magnify"
}, },
@@ -16,18 +13,21 @@
"parental_control_blocked": { "parental_control_blocked": {
"default": "mdi:human-male-girl" "default": "mdi:human-male-girl"
}, },
"rules_count": {
"default": "mdi:counter"
},
"safe_browsing_blocked": { "safe_browsing_blocked": {
"default": "mdi:shield-half-full" "default": "mdi:shield-half-full"
}, },
"safe_searches_enforced": { "safe_searches_enforced": {
"default": "mdi:shield-search" "default": "mdi:shield-search"
},
"average_processing_speed": {
"default": "mdi:speedometer"
},
"rules_count": {
"default": "mdi:counter"
} }
}, },
"switch": { "switch": {
"filtering": { "protection": {
"default": "mdi:shield-check", "default": "mdi:shield-check",
"state": { "state": {
"off": "mdi:shield-off" "off": "mdi:shield-off"
@@ -39,13 +39,7 @@
"off": "mdi:shield-off" "off": "mdi:shield-off"
} }
}, },
"protection": { "safe_search": {
"default": "mdi:shield-check",
"state": {
"off": "mdi:shield-off"
}
},
"query_log": {
"default": "mdi:shield-check", "default": "mdi:shield-check",
"state": { "state": {
"off": "mdi:shield-off" "off": "mdi:shield-off"
@@ -57,7 +51,13 @@
"off": "mdi:shield-off" "off": "mdi:shield-off"
} }
}, },
"safe_search": { "filtering": {
"default": "mdi:shield-check",
"state": {
"off": "mdi:shield-off"
}
},
"query_log": {
"default": "mdi:shield-check", "default": "mdi:shield-check",
"state": { "state": {
"off": "mdi:shield-off" "off": "mdi:shield-off"
@@ -69,17 +69,17 @@
"add_url": { "add_url": {
"service": "mdi:link-plus" "service": "mdi:link-plus"
}, },
"disable_url": { "remove_url": {
"service": "mdi:link-variant-off" "service": "mdi:link-off"
}, },
"enable_url": { "enable_url": {
"service": "mdi:link-variant" "service": "mdi:link-variant"
}, },
"disable_url": {
"service": "mdi:link-variant-off"
},
"refresh": { "refresh": {
"service": "mdi:refresh" "service": "mdi:refresh"
},
"remove_url": {
"service": "mdi:link-off"
} }
} }
} }

View File

@@ -1,38 +1,35 @@
{ {
"config": { "config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"existing_instance_updated": "Updated existing configuration."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": { "step": {
"hassio_confirm": {
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the add-on: {addon}?",
"title": "AdGuard Home via Home Assistant add-on"
},
"user": { "user": {
"description": "Set up your AdGuard Home instance to allow monitoring and control.",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]", "port": "[%key:common::config_flow::data::port%]",
"ssl": "[%key:common::config_flow::data::ssl%]",
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"ssl": "[%key:common::config_flow::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of the device running your AdGuard Home." "host": "The hostname or IP address of the device running your AdGuard Home."
}, }
"description": "Set up your AdGuard Home instance to allow monitoring and control." },
"hassio_confirm": {
"title": "AdGuard Home via Home Assistant add-on",
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the add-on: {addon}?"
} }
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"existing_instance_updated": "Updated existing configuration.",
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
} }
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"average_processing_speed": {
"name": "Average processing speed"
},
"dns_queries": { "dns_queries": {
"name": "DNS queries" "name": "DNS queries"
}, },
@@ -45,91 +42,94 @@
"parental_control_blocked": { "parental_control_blocked": {
"name": "Parental control blocked" "name": "Parental control blocked"
}, },
"rules_count": {
"name": "Rules count"
},
"safe_browsing_blocked": { "safe_browsing_blocked": {
"name": "Safe browsing blocked" "name": "Safe browsing blocked"
}, },
"safe_searches_enforced": { "safe_searches_enforced": {
"name": "Safe searches enforced" "name": "Safe searches enforced"
},
"average_processing_speed": {
"name": "Average processing speed"
},
"rules_count": {
"name": "Rules count"
} }
}, },
"switch": { "switch": {
"filtering": { "protection": {
"name": "Filtering" "name": "Protection"
}, },
"parental": { "parental": {
"name": "Parental control" "name": "Parental control"
}, },
"protection": { "safe_search": {
"name": "Protection" "name": "Safe search"
},
"query_log": {
"name": "Query log"
}, },
"safe_browsing": { "safe_browsing": {
"name": "Safe browsing" "name": "Safe browsing"
}, },
"safe_search": { "filtering": {
"name": "Safe search" "name": "Filtering"
},
"query_log": {
"name": "Query log"
} }
} }
}, },
"services": { "services": {
"add_url": { "add_url": {
"name": "Add URL",
"description": "Adds a new filter subscription to AdGuard Home.", "description": "Adds a new filter subscription to AdGuard Home.",
"fields": { "fields": {
"name": { "name": {
"description": "The name of the filter subscription.", "name": "[%key:common::config_flow::data::name%]",
"name": "[%key:common::config_flow::data::name%]" "description": "The name of the filter subscription."
}, },
"url": { "url": {
"description": "The filter URL to subscribe to, containing the filter rules.", "name": "[%key:common::config_flow::data::url%]",
"name": "[%key:common::config_flow::data::url%]" "description": "The filter URL to subscribe to, containing the filter rules."
} }
}, }
"name": "Add URL"
},
"disable_url": {
"description": "Disables a filter subscription in AdGuard Home.",
"fields": {
"url": {
"description": "The filter subscription URL to disable.",
"name": "[%key:common::config_flow::data::url%]"
}
},
"name": "Disable URL"
},
"enable_url": {
"description": "Enables a filter subscription in AdGuard Home.",
"fields": {
"url": {
"description": "The filter subscription URL to enable.",
"name": "[%key:common::config_flow::data::url%]"
}
},
"name": "Enable URL"
},
"refresh": {
"description": "Refreshes all filter subscriptions in AdGuard Home.",
"fields": {
"force": {
"description": "Force update (bypasses AdGuard Home throttling), omit for a regular refresh.",
"name": "Force"
}
},
"name": "Refresh"
}, },
"remove_url": { "remove_url": {
"name": "Remove URL",
"description": "Removes a filter subscription from AdGuard Home.", "description": "Removes a filter subscription from AdGuard Home.",
"fields": { "fields": {
"url": { "url": {
"description": "The filter subscription URL to remove.", "name": "[%key:common::config_flow::data::url%]",
"name": "[%key:common::config_flow::data::url%]" "description": "The filter subscription URL to remove."
} }
}, }
"name": "Remove URL" },
"enable_url": {
"name": "Enable URL",
"description": "Enables a filter subscription in AdGuard Home.",
"fields": {
"url": {
"name": "[%key:common::config_flow::data::url%]",
"description": "The filter subscription URL to enable."
}
}
},
"disable_url": {
"name": "Disable URL",
"description": "Disables a filter subscription in AdGuard Home.",
"fields": {
"url": {
"name": "[%key:common::config_flow::data::url%]",
"description": "The filter subscription URL to disable."
}
}
},
"refresh": {
"name": "Refresh",
"description": "Refreshes all filter subscriptions in AdGuard Home.",
"fields": {
"force": {
"name": "Force",
"description": "Force update (bypasses AdGuard Home throttling), omit for a regular refresh."
}
}
} }
} }
} }

View File

@@ -1,22 +1,22 @@
{ {
"services": { "services": {
"write_data_by_name": { "write_data_by_name": {
"name": "Write data by name",
"description": "Write a value to the connected ADS device.", "description": "Write a value to the connected ADS device.",
"fields": { "fields": {
"adstype": {
"description": "The data type of the variable to write to.",
"name": "ADS type"
},
"adsvar": { "adsvar": {
"description": "The name of the variable to write to.", "name": "ADS variable",
"name": "ADS variable" "description": "The name of the variable to write to."
},
"adstype": {
"name": "ADS type",
"description": "The data type of the variable to write to."
}, },
"value": { "value": {
"description": "The value to write to the variable.", "name": "Value",
"name": "Value" "description": "The value to write to the variable."
} }
}, }
"name": "Write data by name"
} }
} }
} }

View File

@@ -1,11 +1,11 @@
{ {
"config": { "config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}, },
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"step": { "step": {
"user": { "user": {
"data": { "data": {
@@ -19,14 +19,14 @@
}, },
"services": { "services": {
"set_time_to": { "set_time_to": {
"name": "Set time to",
"description": "Controls timers to turn the system on or off after a set number of minutes.", "description": "Controls timers to turn the system on or off after a set number of minutes.",
"fields": { "fields": {
"minutes": { "minutes": {
"description": "Minutes until action.", "name": "Minutes",
"name": "Minutes" "description": "Minutes until action."
} }
}, }
"name": "Set time to"
} }
} }
} }

View File

@@ -71,14 +71,7 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
} }
) )
return self.async_show_form( return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
step_id="user",
data_schema=schema,
errors=errors,
description_placeholders={
"api_key_url": "https://opendata.aemet.es/centrodedescargas/altaUsuario"
},
)
@staticmethod @staticmethod
@callback @callback

View File

@@ -14,7 +14,7 @@
"longitude": "[%key:common::config_flow::data::longitude%]", "longitude": "[%key:common::config_flow::data::longitude%]",
"name": "Name of the integration" "name": "Name of the integration"
}, },
"description": "To generate API key go to {api_key_url}" "description": "To generate API key go to https://opendata.aemet.es/centrodedescargas/altaUsuario"
} }
} }
}, },

View File

@@ -1,57 +1,57 @@
{ {
"config": { "config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": { "step": {
"user": { "user": {
"data": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]" "api_key": "[%key:common::config_flow::data::api_key%]"
} }
} }
} },
}, "error": {
"issues": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"deprecated_yaml_import_issue_cannot_connect": { },
"description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually.", "abort": {
"title": "The {integration_title} YAML configuration import failed" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
} }
}, },
"services": { "services": {
"add_tracking": { "add_tracking": {
"name": "Add tracking",
"description": "Adds a new tracking number to Aftership.", "description": "Adds a new tracking number to Aftership.",
"fields": { "fields": {
"slug": { "tracking_number": {
"description": "Slug (carrier) of the new tracking.", "name": "Tracking number",
"name": "Slug" "description": "Tracking number for the new tracking."
}, },
"title": { "title": {
"description": "A custom title for the new tracking.", "name": "Title",
"name": "Title" "description": "A custom title for the new tracking."
}, },
"tracking_number": { "slug": {
"description": "Tracking number for the new tracking.", "name": "Slug",
"name": "Tracking number" "description": "Slug (carrier) of the new tracking."
} }
}, }
"name": "Add tracking"
}, },
"remove_tracking": { "remove_tracking": {
"name": "Remove tracking",
"description": "Removes a tracking number from Aftership.", "description": "Removes a tracking number from Aftership.",
"fields": { "fields": {
"slug": {
"description": "Slug (carrier) of the tracking to remove.",
"name": "[%key:component::aftership::services::add_tracking::fields::slug::name%]"
},
"tracking_number": { "tracking_number": {
"description": "Tracking number of the tracking to remove.", "name": "[%key:component::aftership::services::add_tracking::fields::tracking_number::name%]",
"name": "[%key:component::aftership::services::add_tracking::fields::tracking_number::name%]" "description": "Tracking number of the tracking to remove."
},
"slug": {
"name": "[%key:component::aftership::services::add_tracking::fields::slug::name%]",
"description": "Slug (carrier) of the tracking to remove."
} }
}, }
"name": "Remove tracking" }
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The {integration_title} YAML configuration import failed",
"description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
} }
} }
} }

View File

@@ -1,19 +1,19 @@
{ {
"services": { "services": {
"disable_alerts": {
"service": "mdi:bell-off"
},
"enable_alerts": {
"service": "mdi:bell-alert"
},
"snapshot": {
"service": "mdi:camera"
},
"start_recording": { "start_recording": {
"service": "mdi:record-rec" "service": "mdi:record-rec"
}, },
"stop_recording": { "stop_recording": {
"service": "mdi:stop" "service": "mdi:stop"
},
"enable_alerts": {
"service": "mdi:bell-alert"
},
"disable_alerts": {
"service": "mdi:bell-off"
},
"snapshot": {
"service": "mdi:camera"
} }
} }
} }

View File

@@ -1,45 +1,45 @@
{ {
"config": { "config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": { "step": {
"user": { "user": {
"title": "Set up Agent DVR",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
}, },
"data_description": { "data_description": {
"host": "The IP address of the Agent DVR server." "host": "The IP address of the Agent DVR server."
}, }
"title": "Set up Agent DVR"
} }
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
} }
}, },
"services": { "services": {
"disable_alerts": {
"description": "Disables alerts.",
"name": "Disable alerts"
},
"enable_alerts": {
"description": "Enables alerts.",
"name": "Enable alerts"
},
"snapshot": {
"description": "Takes a photo.",
"name": "Snapshot"
},
"start_recording": { "start_recording": {
"description": "Enables continuous recording.", "name": "Start recording",
"name": "Start recording" "description": "Enables continuous recording."
}, },
"stop_recording": { "stop_recording": {
"description": "Disables continuous recording.", "name": "Stop recording",
"name": "Stop recording" "description": "Disables continuous recording."
},
"enable_alerts": {
"name": "Enable alerts",
"description": "Enables alerts."
},
"disable_alerts": {
"name": "Disable alerts",
"description": "Disables alerts."
},
"snapshot": {
"name": "Snapshot",
"description": "Takes a photo."
} }
} }
} }

View File

@@ -53,6 +53,9 @@ __all__ = [
"GenImageTaskResult", "GenImageTaskResult",
"async_generate_data", "async_generate_data",
"async_generate_image", "async_generate_image",
"async_setup",
"async_setup_entry",
"async_unload_entry",
] ]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@@ -30,7 +30,6 @@ generate_data:
media: media:
accept: accept:
- "*" - "*"
multiple: true
generate_image: generate_image:
fields: fields:
task_name: task_name:
@@ -58,4 +57,3 @@ generate_image:
media: media:
accept: accept:
- "*" - "*"
multiple: true

View File

@@ -1,52 +1,52 @@
{ {
"services": { "services": {
"generate_data": { "generate_data": {
"name": "Generate data",
"description": "Uses AI to run a task that generates data.", "description": "Uses AI to run a task that generates data.",
"fields": { "fields": {
"attachments": { "task_name": {
"description": "List of files to attach for multi-modal AI analysis.", "name": "Task name",
"name": "Attachments" "description": "Name of the task."
},
"entity_id": {
"description": "Entity ID to run the task on. If not provided, the preferred entity will be used.",
"name": "Entity ID"
}, },
"instructions": { "instructions": {
"description": "Instructions on what needs to be done.", "name": "Instructions",
"name": "Instructions" "description": "Instructions on what needs to be done."
},
"entity_id": {
"name": "Entity ID",
"description": "Entity ID to run the task on. If not provided, the preferred entity will be used."
}, },
"structure": { "structure": {
"description": "When set, the AI Task will output fields with this in structure. The structure is a dictionary where the keys are the field names and the values contain a 'description', a 'selector', and an optional 'required' field.", "name": "Structured output",
"name": "Structured output" "description": "When set, the AI Task will output fields with this in structure. The structure is a dictionary where the keys are the field names and the values contain a 'description', a 'selector', and an optional 'required' field."
}, },
"task_name": { "attachments": {
"description": "Name of the task.", "name": "Attachments",
"name": "Task name" "description": "List of files to attach for multi-modal AI analysis."
} }
}, }
"name": "Generate data"
}, },
"generate_image": { "generate_image": {
"name": "Generate image",
"description": "Uses AI to generate image.", "description": "Uses AI to generate image.",
"fields": { "fields": {
"attachments": { "task_name": {
"description": "List of files to attach for using as references.", "name": "Task name",
"name": "Attachments" "description": "Name of the task."
},
"entity_id": {
"description": "Entity ID to run the task on.",
"name": "Entity ID"
}, },
"instructions": { "instructions": {
"description": "Instructions that explains the image to be generated.", "name": "Instructions",
"name": "Instructions" "description": "Instructions that explains the image to be generated."
}, },
"task_name": { "entity_id": {
"description": "Name of the task.", "name": "Entity ID",
"name": "Task name" "description": "Entity ID to run the task on."
},
"attachments": {
"name": "Attachments",
"description": "List of files to attach for using as references."
} }
}, }
"name": "Generate image"
} }
} }
} }

View File

@@ -9,17 +9,14 @@
} }
}, },
"number": { "number": {
"display_brightness": { "led_bar_brightness": {
"default": "mdi:brightness-percent" "default": "mdi:brightness-percent"
}, },
"led_bar_brightness": { "display_brightness": {
"default": "mdi:brightness-percent" "default": "mdi:brightness-percent"
} }
}, },
"select": { "select": {
"co2_automatic_baseline_calibration": {
"default": "mdi:molecule-co2"
},
"configuration_control": { "configuration_control": {
"default": "mdi:cloud-cog" "default": "mdi:cloud-cog"
}, },
@@ -34,11 +31,23 @@
}, },
"voc_index_learning_time_offset": { "voc_index_learning_time_offset": {
"default": "mdi:clock-outline" "default": "mdi:clock-outline"
},
"co2_automatic_baseline_calibration": {
"default": "mdi:molecule-co2"
} }
}, },
"sensor": { "sensor": {
"co2_automatic_baseline_calibration": { "total_volatile_organic_component_index": {
"default": "mdi:molecule-co2" "default": "mdi:molecule"
},
"nitrogen_index": {
"default": "mdi:molecule"
},
"pm003_count": {
"default": "mdi:blur"
},
"led_bar_brightness": {
"default": "mdi:brightness-percent"
}, },
"display_brightness": { "display_brightness": {
"default": "mdi:brightness-percent" "default": "mdi:brightness-percent"
@@ -46,26 +55,17 @@
"display_temperature_unit": { "display_temperature_unit": {
"default": "mdi:thermometer-lines" "default": "mdi:thermometer-lines"
}, },
"led_bar_brightness": {
"default": "mdi:brightness-percent"
},
"led_bar_mode": { "led_bar_mode": {
"default": "mdi:led-strip" "default": "mdi:led-strip"
}, },
"nitrogen_index": {
"default": "mdi:molecule"
},
"nox_index_learning_time_offset": { "nox_index_learning_time_offset": {
"default": "mdi:clock-outline" "default": "mdi:clock-outline"
}, },
"pm003_count": {
"default": "mdi:blur"
},
"total_volatile_organic_component_index": {
"default": "mdi:molecule"
},
"voc_index_learning_time_offset": { "voc_index_learning_time_offset": {
"default": "mdi:clock-outline" "default": "mdi:clock-outline"
},
"co2_automatic_baseline_calibration": {
"default": "mdi:molecule-co2"
} }
}, },
"switch": { "switch": {

View File

@@ -1,5 +1,19 @@
{ {
"config": { "config": {
"flow_title": "{model}",
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of the Airgradient device."
}
},
"discovery_confirm": {
"description": "Do you want to set up {model}?"
}
},
"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%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
@@ -10,20 +24,6 @@
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{model}",
"step": {
"discovery_confirm": {
"description": "Do you want to set up {model}?"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of the Airgradient device."
}
}
} }
}, },
"entity": { "entity": {
@@ -36,25 +36,14 @@
} }
}, },
"number": { "number": {
"display_brightness": {
"name": "Display brightness"
},
"led_bar_brightness": { "led_bar_brightness": {
"name": "LED bar brightness" "name": "LED bar brightness"
},
"display_brightness": {
"name": "Display brightness"
} }
}, },
"select": { "select": {
"co2_automatic_baseline_calibration": {
"name": "CO2 automatic baseline duration",
"state": {
"0": "[%key:common::state::off%]",
"1": "1 day",
"8": "8 days",
"30": "30 days",
"90": "90 days",
"180": "180 days"
}
},
"configuration_control": { "configuration_control": {
"name": "Configuration source", "name": "Configuration source",
"state": { "state": {
@@ -62,13 +51,6 @@
"local": "Local" "local": "Local"
} }
}, },
"display_pm_standard": {
"name": "Display PM standard",
"state": {
"ugm3": "μg/m³",
"us_aqi": "US AQI"
}
},
"display_temperature_unit": { "display_temperature_unit": {
"name": "Display temperature unit", "name": "Display temperature unit",
"state": { "state": {
@@ -76,11 +58,18 @@
"f": "Fahrenheit" "f": "Fahrenheit"
} }
}, },
"display_pm_standard": {
"name": "Display PM standard",
"state": {
"ugm3": "μg/m³",
"us_aqi": "US AQI"
}
},
"led_bar_mode": { "led_bar_mode": {
"name": "LED bar mode", "name": "LED bar mode",
"state": { "state": {
"co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"off": "[%key:common::state::off%]", "off": "[%key:common::state::off%]",
"co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"pm": "Particulate matter" "pm": "Particulate matter"
} }
}, },
@@ -103,14 +92,37 @@
"360": "[%key:component::airgradient::entity::select::nox_index_learning_time_offset::state::360%]", "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%]" "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": {
"co2_automatic_baseline_calibration_days": { "total_volatile_organic_component_index": {
"name": "Carbon dioxide automatic baseline calibration" "name": "VOC index"
}, },
"display_brightness": { "nitrogen_index": {
"name": "[%key:component::airgradient::entity::number::display_brightness::name%]" "name": "NOx index"
},
"pm003_count": {
"name": "PM0.3"
},
"raw_total_volatile_organic_component": {
"name": "Raw VOC"
},
"raw_nitrogen": {
"name": "Raw NOx"
},
"raw_pm02": {
"name": "Raw PM2.5"
}, },
"display_pm_standard": { "display_pm_standard": {
"name": "[%key:component::airgradient::entity::select::display_pm_standard::name%]", "name": "[%key:component::airgradient::entity::select::display_pm_standard::name%]",
@@ -119,6 +131,26 @@
"us_aqi": "[%key:component::airgradient::entity::select::display_pm_standard::state::us_aqi%]" "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:common::state::off%]",
"co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"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": { "display_temperature_unit": {
"name": "[%key:component::airgradient::entity::select::display_temperature_unit::name%]", "name": "[%key:component::airgradient::entity::select::display_temperature_unit::name%]",
"state": { "state": {
@@ -126,40 +158,8 @@
"f": "[%key:component::airgradient::entity::select::display_temperature_unit::state::f%]" "f": "[%key:component::airgradient::entity::select::display_temperature_unit::state::f%]"
} }
}, },
"led_bar_brightness": { "display_brightness": {
"name": "[%key:component::airgradient::entity::number::led_bar_brightness::name%]" "name": "[%key:component::airgradient::entity::number::display_brightness::name%]"
},
"led_bar_mode": {
"name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]",
"state": {
"co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"off": "[%key:common::state::off%]",
"pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]"
}
},
"nitrogen_index": {
"name": "NOx index"
},
"nox_learning_offset": {
"name": "[%key:component::airgradient::entity::select::nox_index_learning_time_offset::name%]"
},
"pm003_count": {
"name": "PM0.3"
},
"raw_nitrogen": {
"name": "Raw NOx"
},
"raw_pm02": {
"name": "Raw PM2.5"
},
"raw_total_volatile_organic_component": {
"name": "Raw VOC"
},
"total_volatile_organic_component_index": {
"name": "VOC index"
},
"tvoc_learning_offset": {
"name": "[%key:component::airgradient::entity::select::voc_index_learning_time_offset::name%]"
} }
}, },
"switch": { "switch": {

View File

@@ -1,9 +1,7 @@
"""Airgradient Update platform.""" """Airgradient Update platform."""
from datetime import timedelta from datetime import timedelta
import logging
from airgradient import AirGradientConnectionError
from propcache.api import cached_property from propcache.api import cached_property
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
@@ -15,7 +13,6 @@ from .entity import AirGradientEntity
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(hours=1) SCAN_INTERVAL = timedelta(hours=1)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
@@ -34,7 +31,6 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
"""Representation of Airgradient Update.""" """Representation of Airgradient Update."""
_attr_device_class = UpdateDeviceClass.FIRMWARE _attr_device_class = UpdateDeviceClass.FIRMWARE
_server_unreachable_logged = False
def __init__(self, coordinator: AirGradientCoordinator) -> None: def __init__(self, coordinator: AirGradientCoordinator) -> None:
"""Initialize the entity.""" """Initialize the entity."""
@@ -51,27 +47,10 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
"""Return the installed version of the entity.""" """Return the installed version of the entity."""
return self.coordinator.data.measures.firmware_version return self.coordinator.data.measures.firmware_version
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._attr_available
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update the entity.""" """Update the entity."""
try: self._attr_latest_version = (
self._attr_latest_version = ( await self.coordinator.client.get_latest_firmware_version(
await self.coordinator.client.get_latest_firmware_version( self.coordinator.serial_number
self.coordinator.serial_number
)
) )
except AirGradientConnectionError: )
self._attr_latest_version = None
self._attr_available = False
if not self._server_unreachable_logged:
_LOGGER.error(
"Unable to connect to AirGradient server to check for updates"
)
self._server_unreachable_logged = True
else:
self._server_unreachable_logged = False
self._attr_available = True

View File

@@ -18,10 +18,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS
DESCRIPTION_PLACEHOLDERS = {
"developer_registration_url": "https://developer.airly.eu/register",
}
class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Airly.""" """Config flow for Airly."""
@@ -89,7 +85,6 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
} }
), ),
errors=errors, errors=errors,
description_placeholders=DESCRIPTION_PLACEHOLDERS,
) )

View File

@@ -1,23 +1,30 @@
{ {
"config": { "config": {
"step": {
"user": {
"description": "To generate API key go to https://developer.airly.eu/register",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]"
}
}
},
"error": {
"wrong_location": "No Airly measuring stations in this area.",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
},
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]", "already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"wrong_location": "[%key:component::airly::config::error::wrong_location%]" "wrong_location": "[%key:component::airly::config::error::wrong_location%]"
}, }
"error": { },
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "system_health": {
"wrong_location": "No Airly measuring stations in this area." "info": {
}, "can_reach_server": "Reach Airly server",
"step": { "requests_remaining": "Remaining allowed requests",
"user": { "requests_per_day": "Allowed requests per day"
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "To generate API key go to {developer_registration_url}"
}
} }
}, },
"entity": { "entity": {
@@ -31,18 +38,11 @@
} }
}, },
"exceptions": { "exceptions": {
"no_station": {
"message": "An error occurred while retrieving data from the Airly API for {entry}: no measuring stations in this area"
},
"update_error": { "update_error": {
"message": "An error occurred while retrieving data from the Airly API for {entry}: {error}" "message": "An error occurred while retrieving data from the Airly API for {entry}: {error}"
} },
}, "no_station": {
"system_health": { "message": "An error occurred while retrieving data from the Airly API for {entry}: no measuring stations in this area"
"info": {
"can_reach_server": "Reach Airly server",
"requests_per_day": "Allowed requests per day",
"requests_remaining": "Remaining allowed requests"
} }
} }
} }

View File

@@ -26,10 +26,6 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Documentation URL for API key generation
_API_KEY_URL = "https://docs.airnowapi.org/account/request/"
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
"""Validate the user input allows us to connect. """Validate the user input allows us to connect.
@@ -118,7 +114,6 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN):
), ),
} }
), ),
description_placeholders={"api_key_url": _API_KEY_URL},
errors=errors, errors=errors,
) )

View File

@@ -4,15 +4,15 @@
"aqi": { "aqi": {
"default": "mdi:blur" "default": "mdi:blur"
}, },
"o3": {
"default": "mdi:blur"
},
"pm10": { "pm10": {
"default": "mdi:blur" "default": "mdi:blur"
}, },
"pm25": { "pm25": {
"default": "mdi:blur" "default": "mdi:blur"
}, },
"o3": {
"default": "mdi:blur"
},
"station": { "station": {
"default": "mdi:blur" "default": "mdi:blur"
} }

View File

@@ -1,7 +1,15 @@
{ {
"config": { "config": {
"abort": { "step": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "user": {
"description": "To generate API key go to https://docs.airnowapi.org/account/request/",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"radius": "Station radius (miles; optional)"
}
}
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,15 +17,16 @@
"invalid_location": "No results found for that location, try changing the location or station radius.", "invalid_location": "No results found for that location, try changing the location or station radius.",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {
"step": { "step": {
"user": { "init": {
"data": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]", "radius": "Station radius (miles)"
"latitude": "[%key:common::config_flow::data::latitude%]", }
"longitude": "[%key:common::config_flow::data::longitude%]",
"radius": "Station radius (miles; optional)"
},
"description": "To generate API key go to {api_key_url}"
} }
} }
}, },
@@ -34,14 +43,5 @@
} }
} }
} }
},
"options": {
"step": {
"init": {
"data": {
"radius": "Station radius (miles)"
}
}
}
} }
} }

View File

@@ -2,23 +2,12 @@
from __future__ import annotations from __future__ import annotations
import logging
from airos.airos8 import AirOS8 from airos.airos8 import AirOS8
from homeassistant.const import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
CONF_HOST, from homeassistant.core import HomeAssistant
CONF_PASSWORD,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
_PLATFORMS: list[Platform] = [ _PLATFORMS: list[Platform] = [
@@ -26,24 +15,19 @@ _PLATFORMS: list[Platform] = [
Platform.SENSOR, Platform.SENSOR,
] ]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Set up Ubiquiti airOS from a config entry.""" """Set up Ubiquiti airOS from a config entry."""
# By default airOS 8 comes with self-signed SSL certificates, # By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate. # with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession( session = async_get_clientsession(hass, verify_ssl=False)
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
)
airos_device = AirOS8( airos_device = AirOS8(
host=entry.data[CONF_HOST], host=entry.data[CONF_HOST],
username=entry.data[CONF_USERNAME], username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD], password=entry.data[CONF_PASSWORD],
session=session, session=session,
use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
) )
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
@@ -56,77 +40,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
return True return True
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Migrate old config entry."""
# This means the user has downgraded from a future version
if entry.version > 2:
return False
# 1.1 Migrate config_entry to add advanced ssl settings
if entry.version == 1 and entry.minor_version == 1:
new_minor_version = 2
new_data = {**entry.data}
advanced_data = {
CONF_SSL: DEFAULT_SSL,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
}
new_data[SECTION_ADVANCED_SETTINGS] = advanced_data
hass.config_entries.async_update_entry(
entry,
data=new_data,
minor_version=new_minor_version,
)
# 2.1 Migrate binary_sensor entity unique_id from device_id to mac_address
# Step 1 - migrate binary_sensor entity unique_id
# Step 2 - migrate device entity identifier
if entry.version == 1:
new_version = 2
new_minor_version = 1
mac_adress = dr.format_mac(entry.unique_id)
device_registry = dr.async_get(hass)
if device_entry := device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac_adress)}
):
old_device_id = next(
(
device_id
for domain, device_id in device_entry.identifiers
if domain == DOMAIN
),
)
@callback
def update_unique_id(
entity_entry: er.RegistryEntry,
) -> dict[str, str] | None:
"""Update unique id from device_id to mac address."""
if old_device_id and entity_entry.unique_id.startswith(old_device_id):
suffix = entity_entry.unique_id.removeprefix(old_device_id)
new_unique_id = f"{mac_adress}{suffix}"
return {"new_unique_id": new_unique_id}
return None
await er.async_migrate_entries(hass, entry.entry_id, update_unique_id)
new_identifiers = device_entry.identifiers.copy()
new_identifiers.discard((DOMAIN, old_device_id))
new_identifiers.add((DOMAIN, mac_adress))
device_registry.async_update_device(
device_entry.id, new_identifiers=new_identifiers
)
hass.config_entries.async_update_entry(
entry, version=new_version, minor_version=new_minor_version
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> 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

@@ -98,7 +98,7 @@ class AirOSBinarySensor(AirOSEntity, BinarySensorEntity):
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}" self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}"
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
@@ -15,28 +14,11 @@ from airos.exceptions import (
) )
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ( from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
SOURCE_REAUTH, from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.data_entry_flow import section
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS from .const import DOMAIN
from .coordinator import AirOS8 from .coordinator import AirOS8
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -46,15 +28,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_HOST): str, vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default="ubnt"): str, vol.Required(CONF_USERNAME, default="ubnt"): str,
vol.Required(CONF_PASSWORD): str, vol.Required(CONF_PASSWORD): str,
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(CONF_SSL, default=DEFAULT_SSL): bool,
vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
}
),
{"collapsed": True},
),
} }
) )
@@ -62,161 +35,48 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS.""" """Handle a config flow for Ubiquiti airOS."""
VERSION = 2 VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.airos_device: AirOS8
self.errors: dict[str, str] = {}
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the manual input of host and credentials.""" """Handle the initial step."""
self.errors = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
validated_info = await self._validate_and_get_device_info(user_input) # By default airOS 8 comes with self-signed SSL certificates,
if validated_info: # with no option in the web UI to change or upload a custom certificate.
return self.async_create_entry( session = async_get_clientsession(self.hass, verify_ssl=False)
title=validated_info["title"],
data=validated_info["data"],
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors
)
async def _validate_and_get_device_info( airos_device = AirOS8(
self, config_data: dict[str, Any] host=user_input[CONF_HOST],
) -> dict[str, Any] | None: username=user_input[CONF_USERNAME],
"""Validate user input with the device API.""" password=user_input[CONF_PASSWORD],
# By default airOS 8 comes with self-signed SSL certificates, session=session,
# with no option in the web UI to change or upload a custom certificate. )
session = async_get_clientsession( try:
self.hass, await airos_device.login()
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL], airos_data = await airos_device.status()
)
airos_device = AirOS8( except (
host=config_data[CONF_HOST], AirOSConnectionSetupError,
username=config_data[CONF_USERNAME], AirOSDeviceConnectionError,
password=config_data[CONF_PASSWORD], ):
session=session, errors["base"] = "cannot_connect"
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL], except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
) errors["base"] = "invalid_auth"
try: except AirOSKeyDataMissingError:
await airos_device.login() errors["base"] = "key_data_missing"
airos_data = await airos_device.status() except Exception:
_LOGGER.exception("Unexpected exception")
except ( errors["base"] = "unknown"
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
):
self.errors["base"] = "cannot_connect"
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
self.errors["base"] = "invalid_auth"
except AirOSKeyDataMissingError:
self.errors["base"] = "key_data_missing"
except Exception:
_LOGGER.exception("Unexpected exception during credential validation")
self.errors["base"] = "unknown"
else:
await self.async_set_unique_id(airos_data.derived.mac)
if self.source in [SOURCE_REAUTH, SOURCE_RECONFIGURE]:
self._abort_if_unique_id_mismatch()
else: else:
await self.async_set_unique_id(airos_data.derived.mac)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry(
return {"title": airos_data.host.hostname, "data": config_data} title=airos_data.host.hostname, data=user_input
return None
async def async_step_reauth(
self,
user_input: Mapping[str, Any],
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm(user_input)
async def async_step_reauth_confirm(
self,
user_input: Mapping[str, Any],
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
self.errors = {}
if user_input:
validate_data = {**self._get_reauth_entry().data, **user_input}
if await self._validate_and_get_device_info(config_data=validate_data):
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=validate_data,
) )
return self.async_show_form( return self.async_show_form(
step_id="reauth_confirm", step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
),
errors=self.errors,
)
async def async_step_reconfigure(
self,
user_input: Mapping[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle reconfiguration of airOS."""
self.errors = {}
entry = self._get_reconfigure_entry()
current_data = entry.data
if user_input is not None:
validate_data = {**current_data, **user_input}
if await self._validate_and_get_device_info(config_data=validate_data):
return self.async_update_reload_and_abort(
entry,
data_updates=validate_data,
)
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(
CONF_SSL,
default=current_data[SECTION_ADVANCED_SETTINGS][
CONF_SSL
],
): bool,
vol.Required(
CONF_VERIFY_SSL,
default=current_data[SECTION_ADVANCED_SETTINGS][
CONF_VERIFY_SSL
],
): bool,
}
),
{"collapsed": True},
),
}
),
errors=self.errors,
) )

View File

@@ -7,8 +7,3 @@ DOMAIN = "airos"
SCAN_INTERVAL = timedelta(minutes=1) SCAN_INTERVAL = timedelta(minutes=1)
MANUFACTURER = "Ubiquiti" MANUFACTURER = "Ubiquiti"
DEFAULT_VERIFY_SSL = False
DEFAULT_SSL = True
SECTION_ADVANCED_SETTINGS = "advanced_settings"

View File

@@ -14,7 +14,7 @@ from airos.exceptions import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL from .const import DOMAIN, SCAN_INTERVAL
@@ -47,9 +47,9 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
try: try:
await self.airos_device.login() await self.airos_device.login()
return await self.airos_device.status() return await self.airos_device.status()
except AirOSConnectionAuthenticationError as err: except (AirOSConnectionAuthenticationError,) as err:
_LOGGER.exception("Error authenticating with airOS device") _LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryAuthFailed( raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="invalid_auth" translation_domain=DOMAIN, translation_key="invalid_auth"
) from err ) from err
except ( except (

View File

@@ -2,11 +2,11 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.const import CONF_HOST, CONF_SSL from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS from .const import DOMAIN, MANUFACTURER
from .coordinator import AirOSDataUpdateCoordinator from .coordinator import AirOSDataUpdateCoordinator
@@ -20,27 +20,17 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
super().__init__(coordinator) super().__init__(coordinator)
airos_data = self.coordinator.data airos_data = self.coordinator.data
url_schema = (
"https"
if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
else "http"
)
configuration_url: str | None = ( configuration_url: str | None = (
f"{url_schema}://{coordinator.config_entry.data[CONF_HOST]}" f"https://{coordinator.config_entry.data[CONF_HOST]}"
) )
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)}, connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)},
configuration_url=configuration_url, configuration_url=configuration_url,
identifiers={(DOMAIN, airos_data.derived.mac)}, identifiers={(DOMAIN, str(airos_data.host.device_id))},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=airos_data.host.devmodel, model=airos_data.host.devmodel,
model_id=(
sku
if (sku := airos_data.derived.sku) not in ["UNKNOWN", "AMBIGUOUS"]
else None
),
name=airos_data.host.hostname, name=airos_data.host.hostname,
sw_version=airos_data.host.fwversion, sw_version=airos_data.host.fwversion,
) )

View File

@@ -4,8 +4,7 @@
"codeowners": ["@CoMPaTech"], "codeowners": ["@CoMPaTech"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airos", "documentation": "https://www.home-assistant.io/integrations/airos",
"integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "silver", "quality_scale": "bronze",
"requirements": ["airos==0.6.0"] "requirements": ["airos==0.5.1"]
} }

View File

@@ -32,11 +32,11 @@ rules:
config-entry-unloading: done config-entry-unloading: done
docs-configuration-parameters: done docs-configuration-parameters: done
docs-installation-parameters: done docs-installation-parameters: done
entity-unavailable: done entity-unavailable: todo
integration-owner: done integration-owner: done
log-when-unavailable: done log-when-unavailable: todo
parallel-updates: done parallel-updates: todo
reauthentication-flow: done reauthentication-flow: todo
test-coverage: done test-coverage: done
# Gold # Gold
@@ -48,9 +48,9 @@ rules:
docs-examples: todo docs-examples: todo
docs-known-limitations: done docs-known-limitations: done
docs-supported-devices: done docs-supported-devices: done
docs-supported-functions: done docs-supported-functions: todo
docs-troubleshooting: done docs-troubleshooting: done
docs-use-cases: done docs-use-cases: todo
dynamic-devices: todo dynamic-devices: todo
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
@@ -60,7 +60,7 @@ rules:
icon-translations: icon-translations:
status: exempt status: exempt
comment: no (custom) icons used or envisioned comment: no (custom) icons used or envisioned
reconfiguration-flow: done reconfiguration-flow: todo
repair-issues: todo repair-issues: todo
stale-devices: todo stale-devices: todo

View File

@@ -1,10 +1,19 @@
{ {
"config": { "config": {
"abort": { "flow_title": "Ubiquiti airOS device",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "step": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "user": {
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "data": {
"unique_id_mismatch": "Re-authentication should be used for the same device not a new one" "host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "IP address or hostname of the airOS device",
"username": "Administrator username for the airOS device, normally 'ubnt'",
"password": "Password configured through the UISP app or web interface"
}
}
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -12,68 +21,14 @@
"key_data_missing": "Expected data not returned from the device, check the documentation for supported devices", "key_data_missing": "Expected data not returned from the device, check the documentation for supported devices",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"flow_title": "Ubiquiti airOS device", "abort": {
"step": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::user::data_description::password%]"
}
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::user::data_description::password%]"
},
"sections": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::user::sections::advanced_settings::name%]"
}
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "IP address or hostname of the airOS device",
"password": "Password configured through the UISP app or web interface",
"username": "Administrator username for the airOS device, normally 'ubnt'"
},
"sections": {
"advanced_settings": {
"data": {
"ssl": "Use HTTPS",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "Whether the connection should be encrypted (required for most devices)",
"verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates"
},
"name": "Advanced settings"
}
}
}
} }
}, },
"entity": { "entity": {
"binary_sensor": { "binary_sensor": {
"dhcp6_server": { "port_forwarding": {
"name": "DHCPv6 server" "name": "Port forwarding"
}, },
"dhcp_client": { "dhcp_client": {
"name": "DHCP client" "name": "DHCP client"
@@ -81,8 +36,8 @@
"dhcp_server": { "dhcp_server": {
"name": "DHCP server" "name": "DHCP server"
}, },
"port_forwarding": { "dhcp6_server": {
"name": "Port forwarding" "name": "DHCPv6 server"
}, },
"pppoe": { "pppoe": {
"name": "PPPoE link" "name": "PPPoE link"
@@ -99,27 +54,20 @@
"router": "Router" "router": "Router"
} }
}, },
"host_uptime": { "wireless_frequency": {
"name": "Uptime" "name": "Wireless frequency"
},
"wireless_antenna_gain": {
"name": "Antenna gain"
},
"wireless_distance": {
"name": "Wireless distance"
}, },
"wireless_essid": { "wireless_essid": {
"name": "Wireless SSID" "name": "Wireless SSID"
}, },
"wireless_frequency": { "wireless_antenna_gain": {
"name": "Wireless frequency" "name": "Antenna gain"
}, },
"wireless_mode": { "wireless_throughput_tx": {
"name": "Wireless mode", "name": "Throughput transmit (actual)"
"state": { },
"point_to_multipoint": "Point-to-multipoint", "wireless_throughput_rx": {
"point_to_point": "Point-to-point" "name": "Throughput receive (actual)"
}
}, },
"wireless_polling_dl_capacity": { "wireless_polling_dl_capacity": {
"name": "Download capacity" "name": "Download capacity"
@@ -130,6 +78,12 @@
"wireless_remote_hostname": { "wireless_remote_hostname": {
"name": "Remote hostname" "name": "Remote hostname"
}, },
"host_uptime": {
"name": "Uptime"
},
"wireless_distance": {
"name": "Wireless distance"
},
"wireless_role": { "wireless_role": {
"name": "Wireless role", "name": "Wireless role",
"state": { "state": {
@@ -137,26 +91,27 @@
"station": "Station" "station": "Station"
} }
}, },
"wireless_throughput_rx": { "wireless_mode": {
"name": "Throughput receive (actual)" "name": "Wireless mode",
}, "state": {
"wireless_throughput_tx": { "point_to_point": "Point-to-point",
"name": "Throughput transmit (actual)" "point_to_multipoint": "Point-to-multipoint"
}
} }
} }
}, },
"exceptions": { "exceptions": {
"cannot_connect": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"error_data_missing": {
"message": "Data incomplete or missing"
},
"invalid_auth": { "invalid_auth": {
"message": "[%key:common::config_flow::error::invalid_auth%]" "message": "[%key:common::config_flow::error::invalid_auth%]"
}, },
"cannot_connect": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"key_data_missing": { "key_data_missing": {
"message": "Key data not returned from device" "message": "Key data not returned from device"
},
"error_data_missing": {
"message": "Data incomplete or missing"
} }
} }
} }

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairq"], "loggers": ["aioairq"],
"requirements": ["aioairq==0.4.7"] "requirements": ["aioairq==0.4.6"]
} }

View File

@@ -1,21 +1,36 @@
{ {
"config": { "config": {
"abort": { "step": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "user": {
"title": "Identify the device",
"description": "Provide the IP address or mDNS of the device and its password",
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_input": "[%key:common::config_flow::error::invalid_host%]" "invalid_input": "[%key:common::config_flow::error::invalid_host%]"
}, },
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {
"step": { "step": {
"user": { "init": {
"title": "Configure air-Q integration",
"data": { "data": {
"ip_address": "[%key:common::config_flow::data::ip%]", "return_average": "Show values averaged by the device",
"password": "[%key:common::config_flow::data::password%]" "clip_negatives": "Clip negative values"
}, },
"description": "Provide the IP address or mDNS of the device and its password", "data_description": {
"title": "Identify the device" "return_average": "air-Q allows to poll both the noisy sensor readings as well as the values averaged on the device (default)",
"clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behaviour is to clip such values to 0"
}
} }
} }
}, },
@@ -38,11 +53,8 @@
"bromine": { "bromine": {
"name": "Bromine" "name": "Bromine"
}, },
"carbon_disulfide": { "methanethiol": {
"name": "Carbon disulfide" "name": "Methanethiol"
},
"carbon_monoxide": {
"name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]"
}, },
"chlorine": { "chlorine": {
"name": "Chlorine" "name": "Chlorine"
@@ -50,6 +62,12 @@
"chlorine_dioxide": { "chlorine_dioxide": {
"name": "Chlorine dioxide" "name": "Chlorine dioxide"
}, },
"carbon_disulfide": {
"name": "Carbon disulfide"
},
"carbon_monoxide": {
"name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]"
},
"dew_point": { "dew_point": {
"name": "Dew point" "name": "Dew point"
}, },
@@ -59,51 +77,36 @@
"ethylene": { "ethylene": {
"name": "Ethylene" "name": "Ethylene"
}, },
"fluorine": {
"name": "Fluorine"
},
"formaldehyde": { "formaldehyde": {
"name": "Formaldehyde" "name": "Formaldehyde"
}, },
"health_index": { "fluorine": {
"name": "Health index" "name": "Fluorine"
},
"hydrogen_sulfide": {
"name": "Hydrogen sulfide"
}, },
"hydrochloric_acid": { "hydrochloric_acid": {
"name": "Hydrochloric acid" "name": "Hydrochloric acid"
}, },
"hydrogen": {
"name": "Hydrogen"
},
"hydrogen_cyanide": { "hydrogen_cyanide": {
"name": "Hydrogen cyanide" "name": "Hydrogen cyanide"
}, },
"hydrogen_fluoride": { "hydrogen_fluoride": {
"name": "Hydrogen fluoride" "name": "Hydrogen fluoride"
}, },
"health_index": {
"name": "Health index"
},
"hydrogen": {
"name": "Hydrogen"
},
"hydrogen_peroxide": { "hydrogen_peroxide": {
"name": "Hydrogen peroxide" "name": "Hydrogen peroxide"
}, },
"hydrogen_phosphide": {
"name": "Hydrogen phosphide"
},
"hydrogen_sulfide": {
"name": "Hydrogen sulfide"
},
"industrial_volatile_organic_compounds": {
"name": "VOCs (industrial)"
},
"maximum_noise": {
"name": "Noise (maximum)"
},
"methane": { "methane": {
"name": "Methane" "name": "Methane"
}, },
"methanethiol": {
"name": "Methanethiol"
},
"noise": {
"name": "Noise"
},
"organic_acid": { "organic_acid": {
"name": "Organic acid" "name": "Organic acid"
}, },
@@ -113,39 +116,36 @@
"performance_index": { "performance_index": {
"name": "Performance index" "name": "Performance index"
}, },
"propane": { "hydrogen_phosphide": {
"name": "Propane" "name": "Hydrogen phosphide"
},
"radon": {
"name": "Radon"
},
"refigerant": {
"name": "Refrigerant"
}, },
"relative_pressure": { "relative_pressure": {
"name": "Relative pressure" "name": "Relative pressure"
}, },
"propane": {
"name": "Propane"
},
"refigerant": {
"name": "Refrigerant"
},
"silicon_hydride": { "silicon_hydride": {
"name": "Silicon hydride" "name": "Silicon hydride"
}, },
"noise": {
"name": "Noise"
},
"maximum_noise": {
"name": "Noise (maximum)"
},
"radon": {
"name": "Radon"
},
"industrial_volatile_organic_compounds": {
"name": "VOCs (industrial)"
},
"virus_index": { "virus_index": {
"name": "Virus index" "name": "Virus index"
} }
} }
},
"options": {
"step": {
"init": {
"data": {
"clip_negatives": "Clip negative values",
"return_average": "Show values averaged by the device"
},
"data_description": {
"clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behavior is to clip such values to 0",
"return_average": "air-Q allows to poll both the noisy sensor readings as well as the values averaged on the device (default)"
},
"title": "Configure air-Q integration"
}
}
} }
} }

View File

@@ -23,10 +23,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
} }
) )
URL_API_INTEGRATION = {
"url": "https://dashboard.airthings.com/integrations/api-integration"
}
class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Airthings.""" """Handle a config flow for Airthings."""
@@ -41,7 +37,11 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=STEP_USER_DATA_SCHEMA, data_schema=STEP_USER_DATA_SCHEMA,
description_placeholders=URL_API_INTEGRATION, description_placeholders={
"url": (
"https://dashboard.airthings.com/integrations/api-integration"
),
},
) )
errors = {} errors = {}
@@ -65,8 +65,5 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="Airthings", data=user_input) return self.async_create_entry(title="Airthings", data=user_input)
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders=URL_API_INTEGRATION,
) )

View File

@@ -1,36 +1,36 @@
{ {
"config": { "config": {
"abort": { "step": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]" "user": {
"data": {
"id": "ID",
"secret": "Secret",
"description": "Login at {url} to find your credentials"
}
}
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"step": { "abort": {
"user": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
"data": {
"id": "ID",
"secret": "Secret"
},
"description": "Log in at {url} to find your credentials"
}
} }
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"light": {
"name": "Light"
},
"mold": {
"name": "Mold"
},
"radon": { "radon": {
"name": "Radon" "name": "Radon"
}, },
"light": {
"name": "Light"
},
"virus_risk": { "virus_risk": {
"name": "Virus Risk" "name": "Virus Risk"
},
"mold": {
"name": "Mold"
} }
} }
} }

View File

@@ -6,13 +6,8 @@ import dataclasses
import logging import logging
from typing import Any from typing import Any
from airthings_ble import ( from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice
AirthingsBluetoothDeviceData,
AirthingsDevice,
UnsupportedDeviceError,
)
from bleak import BleakError from bleak import BleakError
from habluetooth import BluetoothServiceInfoBleak
import voluptuous as vol import voluptuous as vol
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
@@ -23,7 +18,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS from homeassistant.const import CONF_ADDRESS
from .const import DEVICE_MODEL, DOMAIN, MFCT_ID from .const import DOMAIN, MFCT_ID
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -32,7 +27,6 @@ SERVICE_UUIDS = [
"b42e4a8e-ade7-11e4-89d3-123b93f75cba", "b42e4a8e-ade7-11e4-89d3-123b93f75cba",
"b42e1c08-ade7-11e4-89d3-123b93f75cba", "b42e1c08-ade7-11e4-89d3-123b93f75cba",
"b42e3882-ade7-11e4-89d3-123b93f75cba", "b42e3882-ade7-11e4-89d3-123b93f75cba",
"b42e90a2-ade7-11e4-89d3-123b93f75cba",
] ]
@@ -43,7 +37,6 @@ class Discovery:
name: str name: str
discovery_info: BluetoothServiceInfo discovery_info: BluetoothServiceInfo
device: AirthingsDevice device: AirthingsDevice
data: AirthingsBluetoothDeviceData
def get_name(device: AirthingsDevice) -> str: def get_name(device: AirthingsDevice) -> str:
@@ -51,7 +44,7 @@ def get_name(device: AirthingsDevice) -> str:
name = device.friendly_name() name = device.friendly_name()
if identifier := device.identifier: if identifier := device.identifier:
name += f" ({device.model.value}{identifier})" name += f" ({identifier})"
return name return name
@@ -69,8 +62,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_device: Discovery | None = None self._discovered_device: Discovery | None = None
self._discovered_devices: dict[str, Discovery] = {} self._discovered_devices: dict[str, Discovery] = {}
async def _get_device( async def _get_device_data(
self, data: AirthingsBluetoothDeviceData, discovery_info: BluetoothServiceInfo self, discovery_info: BluetoothServiceInfo
) -> AirthingsDevice: ) -> AirthingsDevice:
ble_device = bluetooth.async_ble_device_from_address( ble_device = bluetooth.async_ble_device_from_address(
self.hass, discovery_info.address self.hass, discovery_info.address
@@ -79,8 +72,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("no ble_device in _get_device_data") _LOGGER.debug("no ble_device in _get_device_data")
raise AirthingsDeviceUpdateError("No ble_device") raise AirthingsDeviceUpdateError("No ble_device")
airthings = AirthingsBluetoothDeviceData(_LOGGER)
try: try:
device = await data.update_device(ble_device) data = await airthings.update_device(ble_device)
except BleakError as err: except BleakError as err:
_LOGGER.error( _LOGGER.error(
"Error connecting to and getting data from %s: %s", "Error connecting to and getting data from %s: %s",
@@ -88,15 +83,12 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
err, err,
) )
raise AirthingsDeviceUpdateError("Failed getting device data") from err raise AirthingsDeviceUpdateError("Failed getting device data") from err
except UnsupportedDeviceError:
_LOGGER.debug("Skipping unsupported device: %s", discovery_info.name)
raise
except Exception as err: except Exception as err:
_LOGGER.error( _LOGGER.error(
"Unknown error occurred from %s: %s", discovery_info.address, err "Unknown error occurred from %s: %s", discovery_info.address, err
) )
raise raise
return device return data
async def async_step_bluetooth( async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo self, discovery_info: BluetoothServiceInfo
@@ -106,21 +98,17 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info.address) await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
data = AirthingsBluetoothDeviceData(logger=_LOGGER)
try: try:
device = await self._get_device(data=data, discovery_info=discovery_info) device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError: except AirthingsDeviceUpdateError:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
except UnsupportedDeviceError:
return self.async_abort(reason="unsupported_device")
except Exception: except Exception:
_LOGGER.exception("Unknown error occurred") _LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
name = get_name(device) name = get_name(device)
self.context["title_placeholders"] = {"name": name} self.context["title_placeholders"] = {"name": name}
self._discovered_device = Discovery(name, discovery_info, device, data=data) self._discovered_device = Discovery(name, discovery_info, device)
return await self.async_step_bluetooth_confirm() return await self.async_step_bluetooth_confirm()
@@ -128,15 +116,9 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Confirm discovery.""" """Confirm discovery."""
assert self._discovered_device is not None
if user_input is not None: if user_input is not None:
if self._discovered_device.device.firmware.need_firmware_upgrade:
return self.async_abort(reason="firmware_upgrade_required")
return self.async_create_entry( return self.async_create_entry(
title=self.context["title_placeholders"]["name"], title=self.context["title_placeholders"]["name"], data={}
data={DEVICE_MODEL: self._discovered_device.device.model.value},
) )
self._set_confirm_only() self._set_confirm_only()
@@ -155,68 +137,41 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
discovery = self._discovered_devices[address] discovery = self._discovered_devices[address]
if discovery.device.firmware.need_firmware_upgrade:
return self.async_abort(reason="firmware_upgrade_required")
self.context["title_placeholders"] = { self.context["title_placeholders"] = {
"name": discovery.name, "name": discovery.name,
} }
self._discovered_device = discovery self._discovered_device = discovery
return self.async_create_entry( return self.async_create_entry(title=discovery.name, data={})
title=discovery.name,
data={DEVICE_MODEL: discovery.device.model.value},
)
current_addresses = self._async_current_ids(include_ignore=False) current_addresses = self._async_current_ids(include_ignore=False)
devices: list[BluetoothServiceInfoBleak] = []
for discovery_info in async_discovered_service_info(self.hass): for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:
continue continue
if MFCT_ID not in discovery_info.manufacturer_data: if MFCT_ID not in discovery_info.manufacturer_data:
continue continue
if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
_LOGGER.debug(
"Skipping unsupported device: %s (%s)", discovery_info.name, address
)
continue
devices.append(discovery_info)
for discovery_info in devices: if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
address = discovery_info.address continue
data = AirthingsBluetoothDeviceData(logger=_LOGGER)
try: try:
device = await self._get_device(data, discovery_info) device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError: except AirthingsDeviceUpdateError:
_LOGGER.error( return self.async_abort(reason="cannot_connect")
"Error connecting to and getting data from %s (%s)",
discovery_info.name,
discovery_info.address,
)
continue
except UnsupportedDeviceError:
_LOGGER.debug(
"Skipping unsupported device: %s (%s)",
discovery_info.name,
discovery_info.address,
)
continue
except Exception: except Exception:
_LOGGER.exception("Unknown error occurred") _LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
name = get_name(device) name = get_name(device)
_LOGGER.debug("Discovered Airthings device: %s (%s)", name, address) self._discovered_devices[address] = Discovery(name, discovery_info, device)
self._discovered_devices[address] = Discovery(
name, discovery_info, device, data
)
if not self._discovered_devices: if not self._discovered_devices:
return self.async_abort(reason="no_devices_found") return self.async_abort(reason="no_devices_found")
titles = { titles = {
address: get_name(discovery.device) address: discovery.device.name
for (address, discovery) in self._discovered_devices.items() for (address, discovery) in self._discovered_devices.items()
} }
return self.async_show_form( return self.async_show_form(

View File

@@ -1,16 +1,11 @@
"""Constants for Airthings BLE.""" """Constants for Airthings BLE."""
from airthings_ble import AirthingsDeviceType
DOMAIN = "airthings_ble" DOMAIN = "airthings_ble"
MFCT_ID = 820 MFCT_ID = 820
VOLUME_BECQUEREL = "Bq/m³" VOLUME_BECQUEREL = "Bq/m³"
VOLUME_PICOCURIE = "pCi/L" VOLUME_PICOCURIE = "pCi/L"
DEVICE_MODEL = "device_model"
DEFAULT_SCAN_INTERVAL = 300 DEFAULT_SCAN_INTERVAL = 300
DEVICE_SPECIFIC_SCAN_INTERVAL = {AirthingsDeviceType.CORENTIUM_HOME_2.value: 1800}
MAX_RETRIES_AFTER_STARTUP = 5 MAX_RETRIES_AFTER_STARTUP = 5

View File

@@ -16,12 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import ( from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
DEFAULT_SCAN_INTERVAL,
DEVICE_MODEL,
DEVICE_SPECIFIC_SCAN_INTERVAL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -39,18 +34,12 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
self.airthings = AirthingsBluetoothDeviceData( self.airthings = AirthingsBluetoothDeviceData(
_LOGGER, hass.config.units is METRIC_SYSTEM _LOGGER, hass.config.units is METRIC_SYSTEM
) )
device_model = entry.data.get(DEVICE_MODEL)
interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get(
device_model, DEFAULT_SCAN_INTERVAL
)
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry, config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=interval), update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
) )
async def _async_setup(self) -> None: async def _async_setup(self) -> None:
@@ -69,29 +58,11 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
) )
self.ble_device = ble_device self.ble_device = ble_device
if DEVICE_MODEL not in self.config_entry.data:
_LOGGER.debug("Fetching device info for migration")
try:
data = await self.airthings.update_device(self.ble_device)
except Exception as err:
raise UpdateFailed(
f"Unable to fetch data for migration: {err}"
) from err
self.hass.config_entries.async_update_entry(
self.config_entry,
data={**self.config_entry.data, DEVICE_MODEL: data.model.value},
)
self.update_interval = timedelta(
seconds=DEVICE_SPECIFIC_SCAN_INTERVAL.get(
data.model.value, DEFAULT_SCAN_INTERVAL
)
)
async def _async_update_data(self) -> AirthingsDevice: async def _async_update_data(self) -> AirthingsDevice:
"""Get data from Airthings BLE.""" """Get data from Airthings BLE."""
try: try:
data = await self.airthings.update_device(self.ble_device) data = await self.airthings.update_device(self.ble_device)
except Exception as err: except Exception as err:
raise UpdateFailed(f"Unable to fetch data: {err}") from err raise UpdateFailed(f"Unable to fetch data: {err}") from err
return data return data

View File

@@ -4,10 +4,10 @@
"radon_1day_avg": { "radon_1day_avg": {
"default": "mdi:radioactive" "default": "mdi:radioactive"
}, },
"radon_1day_level": { "radon_longterm_avg": {
"default": "mdi:radioactive" "default": "mdi:radioactive"
}, },
"radon_longterm_avg": { "radon_1day_level": {
"default": "mdi:radioactive" "default": "mdi:radioactive"
}, },
"radon_longterm_level": { "radon_longterm_level": {

View File

@@ -17,10 +17,6 @@
{ {
"manufacturer_id": 820, "manufacturer_id": 820,
"service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba" "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba"
},
{
"manufacturer_id": 820,
"service_uuid": "b42e90a2-ade7-11e4-89d3-123b93f75cba"
} }
], ],
"codeowners": ["@vincegio", "@LaStrada"], "codeowners": ["@vincegio", "@LaStrada"],
@@ -28,5 +24,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble", "documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["airthings-ble==1.2.0"] "requirements": ["airthings-ble==0.9.2"]
} }

View File

@@ -16,12 +16,10 @@ from homeassistant.components.sensor import (
from homeassistant.const import ( from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE, PERCENTAGE,
EntityCategory, EntityCategory,
Platform, Platform,
UnitOfPressure, UnitOfPressure,
UnitOfSoundPressure,
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@@ -114,25 +112,8 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0, suggested_display_precision=0,
), ),
"lux": SensorEntityDescription(
key="lux",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"noise": SensorEntityDescription(
key="noise",
translation_key="ambient_noise",
device_class=SensorDeviceClass.SOUND_PRESSURE,
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
} }
PARALLEL_UPDATES = 0
@callback @callback
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:

View File

@@ -1,49 +1,41 @@
{ {
"config": { "config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"firmware_upgrade_required": "Your device requires a firmware upgrade. Please use the Airthings app (Android/iOS) to upgrade it.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unsupported_device": "Unsupported device"
},
"flow_title": "{name}", "flow_title": "{name}",
"step": { "step": {
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": { "user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": { "data": {
"address": "[%key:common::config_flow::data::device%]" "address": "[%key:common::config_flow::data::device%]"
}, }
"data_description": { },
"address": "The Airthings devices discovered via Bluetooth." "bluetooth_confirm": {
}, "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
"description": "[%key:component::bluetooth::config::step::user::description%]"
} }
},
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
} }
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"ambient_noise": {
"name": "Ambient noise"
},
"illuminance": {
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
},
"radon_1day_avg": { "radon_1day_avg": {
"name": "Radon 1-day average" "name": "Radon 1-day average"
}, },
"radon_1day_level": {
"name": "Radon 1-day level"
},
"radon_longterm_avg": { "radon_longterm_avg": {
"name": "Radon longterm average" "name": "Radon longterm average"
}, },
"radon_1day_level": {
"name": "Radon 1-day level"
},
"radon_longterm_level": { "radon_longterm_level": {
"name": "Radon longterm level" "name": "Radon longterm level"
},
"illuminance": {
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
} }
} }
} }

View File

@@ -2,14 +2,17 @@
from airtouch4pyapi import AirTouch from airtouch4pyapi import AirTouch
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import AirTouch4ConfigEntry, AirtouchDataUpdateCoordinator from .coordinator import AirtouchDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE] PLATFORMS = [Platform.CLIMATE]
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
"""Set up AirTouch4 from a config entry.""" """Set up AirTouch4 from a config entry."""
@@ -19,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) ->
info = airtouch.GetAcs() info = airtouch.GetAcs()
if not info: if not info:
raise ConfigEntryNotReady raise ConfigEntryNotReady
coordinator = AirtouchDataUpdateCoordinator(hass, entry, airtouch) coordinator = AirtouchDataUpdateCoordinator(hass, airtouch)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator entry.runtime_data = coordinator

View File

@@ -2,34 +2,26 @@
import logging import logging
from airtouch4pyapi import AirTouch
from airtouch4pyapi.airtouch import AirTouchStatus from airtouch4pyapi.airtouch import AirTouchStatus
from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.components.climate import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): class AirtouchDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Airtouch data.""" """Class to manage fetching Airtouch data."""
def __init__( def __init__(self, hass, airtouch):
self, hass: HomeAssistant, entry: AirTouch4ConfigEntry, airtouch: AirTouch
) -> None:
"""Initialize global Airtouch data updater.""" """Initialize global Airtouch data updater."""
self.airtouch = airtouch self.airtouch = airtouch
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
) )

View File

@@ -9,13 +9,13 @@
}, },
"step": { "step": {
"user": { "user": {
"title": "Set up your AirTouch 4 connection details.",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of your AirTouch controller." "host": "The hostname or IP address of your AirTouch controller."
}, }
"title": "Set up your AirTouch 4 connection details."
} }
} }
} }

View File

@@ -1,18 +1,18 @@
{ {
"config": { "config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": { "step": {
"user": { "user": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
} }
} }
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
} }
}, },
"entity": { "entity": {
@@ -21,8 +21,8 @@
"state_attributes": { "state_attributes": {
"fan_mode": { "fan_mode": {
"state": { "state": {
"intelligent_auto": "Intelligent Auto", "turbo": "Turbo",
"turbo": "Turbo" "intelligent_auto": "Intelligent Auto"
} }
} }
} }

View File

@@ -1,11 +1,11 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"pollutant_label": {
"default": "mdi:chemical-weapon"
},
"pollutant_level": { "pollutant_level": {
"default": "mdi:gauge" "default": "mdi:gauge"
},
"pollutant_label": {
"default": "mdi:chemical-weapon"
} }
} }
} }

View File

@@ -1,44 +1,54 @@
{ {
"config": { "config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"location_not_found": "Location not found",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": { "step": {
"geography_by_coords": { "geography_by_coords": {
"title": "Configure a geography",
"description": "Use the AirVisual cloud API to monitor a latitude/longitude.",
"data": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]", "api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]", "latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]" "longitude": "[%key:common::config_flow::data::longitude%]"
}, }
"description": "Use the AirVisual cloud API to monitor a latitude/longitude.",
"title": "Configure a geography"
}, },
"geography_by_name": { "geography_by_name": {
"title": "[%key:component::airvisual::config::step::geography_by_coords::title%]",
"description": "Use the AirVisual cloud API to monitor a city/state/country.",
"data": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]", "api_key": "[%key:common::config_flow::data::api_key%]",
"city": "City", "city": "City",
"country": "[%key:common::config_flow::data::country%]", "state": "State",
"state": "State" "country": "[%key:common::config_flow::data::country%]"
}, }
"description": "Use the AirVisual cloud API to monitor a city/state/country.",
"title": "[%key:component::airvisual::config::step::geography_by_coords::title%]"
}, },
"reauth_confirm": { "reauth_confirm": {
"title": "Re-authenticate AirVisual",
"data": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]" "api_key": "[%key:common::config_flow::data::api_key%]"
}, }
"title": "Re-authenticate AirVisual"
}, },
"user": { "user": {
"description": "Pick what type of AirVisual data you want to monitor.", "title": "Configure AirVisual",
"title": "Configure AirVisual" "description": "Pick what type of AirVisual data you want to monitor."
}
},
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"location_not_found": "Location not found",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"options": {
"step": {
"init": {
"title": "[%key:component::airvisual::config::step::user::title%]",
"data": {
"show_on_map": "Show monitored geography on the map"
}
} }
} }
}, },
@@ -57,29 +67,19 @@
"pollutant_level": { "pollutant_level": {
"state": { "state": {
"good": "Good", "good": "Good",
"hazardous": "Hazardous",
"moderate": "Moderate", "moderate": "Moderate",
"unhealthy": "Unhealthy", "unhealthy": "Unhealthy",
"unhealthy_sensitive": "Unhealthy for sensitive groups", "unhealthy_sensitive": "Unhealthy for sensitive groups",
"very_unhealthy": "Very unhealthy" "very_unhealthy": "Very unhealthy",
"hazardous": "Hazardous"
} }
} }
} }
}, },
"issues": { "issues": {
"airvisual_pro_migration": { "airvisual_pro_migration": {
"description": "AirVisual Pro units are now their own Home Assistant integration (as opposed to be included with the original AirVisual integration that uses the AirVisual cloud API). The Pro device located at `{ip_address}` has automatically been migrated.\n\nAs part of that migration, the Pro's device ID has changed from `{old_device_id}` to `{new_device_id}`. Please update these automations to use the new device ID: {device_automations_string}.", "title": "{ip_address} is now part of the AirVisual Pro integration",
"title": "{ip_address} is now part of the AirVisual Pro integration" "description": "AirVisual Pro units are now their own Home Assistant integration (as opposed to be included with the original AirVisual integration that uses the AirVisual cloud API). The Pro device located at `{ip_address}` has automatically been migrated.\n\nAs part of that migration, the Pro's device ID has changed from `{old_device_id}` to `{new_device_id}`. Please update these automations to use the new device ID: {device_automations_string}."
}
},
"options": {
"step": {
"init": {
"data": {
"show_on_map": "Show monitored geography on the map"
},
"title": "[%key:component::airvisual::config::step::user::title%]"
}
} }
} }
} }

View File

@@ -1,40 +1,40 @@
{ {
"config": { "config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": { "step": {
"reauth_confirm": { "reauth_confirm": {
"description": "[%key:component::airvisual_pro::config::step::user::description%]",
"data": { "data": {
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
}, }
"description": "[%key:component::airvisual_pro::config::step::user::description%]"
}, },
"user": { "user": {
"description": "The password can be retrieved from the AirVisual Pro's UI.",
"data": { "data": {
"ip_address": "[%key:common::config_flow::data::host%]", "ip_address": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
}, },
"data_description": { "data_description": {
"ip_address": "The hostname or IP address of your AirVisual Pro device." "ip_address": "The hostname or IP address of your AirVisual Pro device."
}, }
"description": "The password can be retrieved from the AirVisual Pro's UI."
} }
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"outdoor_air_quality_index": {
"name": "Outdoor air quality index"
},
"pm01": { "pm01": {
"name": "PM0.1" "name": "PM0.1"
},
"outdoor_air_quality_index": {
"name": "Outdoor air quality index"
} }
} }
} }

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==1.0.2"] "requirements": ["aioairzone==1.0.1"]
} }

View File

@@ -10,15 +10,15 @@
"step": { "step": {
"discovered_connection": { "discovered_connection": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]",
"id": "[%key:component::airzone::config::step::user::data::id%]", "id": "[%key:component::airzone::config::step::user::data::id%]",
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
} }
}, },
"user": { "user": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]",
"id": "System ID", "id": "System ID",
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
} }
} }
@@ -37,19 +37,19 @@
"grille_angles": { "grille_angles": {
"name": "Cold angle", "name": "Cold angle",
"state": { "state": {
"40deg": "40°", "90deg": "90°",
"45deg": "45°",
"50deg": "50°", "50deg": "50°",
"90deg": "90°" "45deg": "45°",
"40deg": "40°"
} }
}, },
"heat_angles": { "heat_angles": {
"name": "Heat angle", "name": "Heat angle",
"state": { "state": {
"40deg": "[%key:component::airzone::entity::select::grille_angles::state::40deg%]", "90deg": "[%key:component::airzone::entity::select::grille_angles::state::90deg%]",
"45deg": "[%key:component::airzone::entity::select::grille_angles::state::45deg%]",
"50deg": "[%key:component::airzone::entity::select::grille_angles::state::50deg%]", "50deg": "[%key:component::airzone::entity::select::grille_angles::state::50deg%]",
"90deg": "[%key:component::airzone::entity::select::grille_angles::state::90deg%]" "45deg": "[%key:component::airzone::entity::select::grille_angles::state::45deg%]",
"40deg": "[%key:component::airzone::entity::select::grille_angles::state::40deg%]"
} }
}, },
"modes": { "modes": {
@@ -66,20 +66,20 @@
"q_adapt": { "q_adapt": {
"name": "Q-Adapt", "name": "Q-Adapt",
"state": { "state": {
"maximum": "Maximum", "standard": "Standard",
"minimum": "Minimum",
"power": "Power", "power": "Power",
"silence": "Silence", "silence": "Silence",
"standard": "Standard" "minimum": "Minimum",
"maximum": "Maximum"
} }
}, },
"sleep_times": { "sleep_times": {
"name": "Sleep", "name": "Sleep",
"state": { "state": {
"off": "[%key:common::state::off%]",
"30m": "30 minutes", "30m": "30 minutes",
"60m": "60 minutes", "60m": "60 minutes",
"90m": "90 minutes", "90m": "90 minutes"
"off": "[%key:common::state::off%]"
} }
} }
}, },

View File

@@ -10,8 +10,8 @@
"user": { "user": {
"data": { "data": {
"id": "Installation", "id": "Installation",
"password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]",
"username": "[%key:common::config_flow::data::username%]" "password": "[%key:common::config_flow::data::password%]"
} }
} }
} }
@@ -32,9 +32,9 @@
"air_quality": { "air_quality": {
"name": "Air Quality mode", "name": "Air Quality mode",
"state": { "state": {
"auto": "[%key:common::state::auto%]",
"off": "[%key:common::state::off%]", "off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]" "on": "[%key:common::state::on%]",
"auto": "[%key:common::state::auto%]"
} }
}, },
"modes": { "modes": {

View File

@@ -22,17 +22,6 @@ class OAuth2FlowHandler(
VERSION = CONFIG_FLOW_VERSION VERSION = CONFIG_FLOW_VERSION
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Check we have the cloud integration set up."""
if "cloud" not in self.hass.config.components:
return self.async_abort(
reason="cloud_not_enabled",
description_placeholders={"default_config": "default_config"},
)
return await super().async_step_user(user_input)
async def async_step_reauth( async def async_step_reauth(
self, user_input: Mapping[str, Any] self, user_input: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@@ -1,34 +1,33 @@
{ {
"config": { "config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml.",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"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%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"step": { "step": {
"oauth_discovery": {
"description": "Home Assistant has found an Aladdin Connect device on your network. Press **Submit** to continue setting up Aladdin Connect."
},
"pick_implementation": { "pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}, },
"reauth_confirm": { "reauth_confirm": {
"description": "Aladdin Connect needs to re-authenticate your account", "title": "[%key:common::config_flow::title::reauth%]",
"title": "[%key:common::config_flow::title::reauth%]" "description": "Aladdin Connect needs to re-authenticate your account"
},
"oauth_discovery": {
"description": "Home Assistant has found an Aladdin Connect device on your network. Press **Submit** to continue setting up Aladdin Connect."
} }
},
"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%]",
"wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
} }
} }
} }

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