Merge branch 'dev' into sensor-device-and-state-class-selectors

This commit is contained in:
Jan Bouwhuis
2025-08-20 09:29:47 +02:00
committed by GitHub
3426 changed files with 250800 additions and 45855 deletions

View File

@@ -8,6 +8,7 @@
"PYTHONASYNCIODEBUG": "1" "PYTHONASYNCIODEBUG": "1"
}, },
"features": { "features": {
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/github-cli:1": {} "ghcr.io/devcontainers/features/github-cli:1": {}
}, },
// Port 5683 udp is used by Shelly integration // Port 5683 udp is used by Shelly integration

53
.github/ISSUE_TEMPLATE/task.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Task
description: For staff only - Create a task
type: Task
body:
- type: markdown
attributes:
value: |
## ⚠️ RESTRICTED ACCESS
**This form is restricted to Open Home Foundation staff, authorized contributors, and integration code owners only.**
If you are a community member wanting to contribute, please:
- For bug reports: Use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)
- For feature requests: Submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)
---
### For authorized contributors
Use this form to create tasks for development work, improvements, or other actionable items that need to be tracked.
- type: textarea
id: description
attributes:
label: Description
description: |
Provide a clear and detailed description of the task that needs to be accomplished.
Be specific about what needs to be done, why it's important, and any constraints or requirements.
placeholder: |
Describe the task, including:
- What needs to be done
- Why this task is needed
- Expected outcome
- Any constraints or requirements
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: Additional context
description: |
Any additional information, links, research, or context that would be helpful.
Include links to related issues, research, prototypes, roadmap opportunities etc.
placeholder: |
- Roadmap opportunity: [link]
- Epic: [link]
- Feature request: [link]
- Technical design documents: [link]
- Prototype/mockup: [link]
- Dependencies: [links]
validations:
required: false

File diff suppressed because it is too large Load Diff

View File

@@ -6,3 +6,6 @@ updates:
interval: daily interval: daily
time: "06:00" time: "06:00"
open-pull-requests-limit: 10 open-pull-requests-limit: 10
labels:
- dependency
- github_actions

View File

@@ -27,7 +27,7 @@ jobs:
publish: ${{ steps.version.outputs.publish }} publish: ${{ steps.version.outputs.publish }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -90,7 +90,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Download nightly wheels of frontend - name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations - name: Download translations
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
name: translations name: translations
@@ -190,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@v3.4.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -242,7 +242,7 @@ jobs:
- green - green
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set build additional args - name: Set build additional args
run: | run: |
@@ -256,7 +256,7 @@ jobs:
fi fi
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.4.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -279,7 +279,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Initialize git - name: Initialize git
uses: home-assistant/actions/helpers/git-init@master uses: home-assistant/actions/helpers/git-init@master
@@ -321,23 +321,23 @@ jobs:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@v3.9.1 uses: sigstore/cosign-installer@v3.9.2
with: with:
cosign-release: "v2.2.3" cosign-release: "v2.2.3"
- name: Login to DockerHub - name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant' if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@v3.4.0 uses: docker/login-action@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@v3.4.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -454,7 +454,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -462,7 +462,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations - name: Download translations
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
name: translations name: translations
@@ -499,10 +499,10 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }} HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.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 }}

View File

@@ -37,10 +37,10 @@ on:
type: boolean type: boolean
env: env:
CACHE_VERSION: 3 CACHE_VERSION: 5
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.7" HA_SHORT_VERSION: "2025.9"
DEFAULT_PYTHON: "3.13" DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']" ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version # 10.3 is the oldest supported version
@@ -94,7 +94,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Generate partial Python venv restore key - name: Generate partial Python venv restore key
id: generate_python_cache_key id: generate_python_cache_key
run: | run: |
@@ -246,7 +246,7 @@ jobs:
- info - info
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -255,7 +255,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v4.2.3 uses: actions/cache@v4.2.4
with: with:
path: venv path: venv
key: >- key: >-
@@ -271,7 +271,7 @@ jobs:
uv pip install "$(cat requirements_test.txt | grep pre-commit)" uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v4.2.3 uses: actions/cache@v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true lookup-only: true
@@ -292,7 +292,7 @@ jobs:
- pre-commit - pre-commit
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
id: python id: python
@@ -301,7 +301,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -310,7 +310,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -332,7 +332,7 @@ jobs:
- pre-commit - pre-commit
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
id: python id: python
@@ -341,7 +341,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -350,7 +350,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -372,7 +372,7 @@ jobs:
- pre-commit - pre-commit
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
id: python id: python
@@ -381,7 +381,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -390,7 +390,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -462,7 +462,7 @@ jobs:
- script/hassfest/docker/Dockerfile - script/hassfest/docker/Dockerfile
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Register hadolint problem matcher - name: Register hadolint problem matcher
run: | run: |
echo "::add-matcher::.github/workflows/matchers/hadolint.json" echo "::add-matcher::.github/workflows/matchers/hadolint.json"
@@ -481,7 +481,7 @@ jobs:
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -497,7 +497,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v4.2.3 uses: actions/cache@v4.2.4
with: with:
path: venv path: venv
key: >- key: >-
@@ -505,7 +505,7 @@ jobs:
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache - name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@v4.2.3 uses: actions/cache@v4.2.4
with: with:
path: ${{ env.UV_CACHE_DIR }} path: ${{ env.UV_CACHE_DIR }}
key: >- key: >-
@@ -584,7 +584,7 @@ jobs:
sudo apt-get -y install \ sudo apt-get -y install \
libturbojpeg libturbojpeg
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -593,7 +593,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -617,7 +617,7 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -626,7 +626,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -651,9 +651,9 @@ jobs:
&& github.event_name == 'pull_request' && github.event_name == 'pull_request'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Dependency review - name: Dependency review
uses: actions/dependency-review-action@v4.7.1 uses: actions/dependency-review-action@v4.7.2
with: with:
license-check: false # We use our own license audit checks license-check: false # We use our own license audit checks
@@ -674,7 +674,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }} python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -683,7 +683,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -717,7 +717,7 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -726,7 +726,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -764,7 +764,7 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -773,7 +773,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -809,7 +809,7 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -825,7 +825,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -833,7 +833,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Restore mypy cache - name: Restore mypy cache
uses: actions/cache@v4.2.3 uses: actions/cache@v4.2.4
with: with:
path: .mypy_cache path: .mypy_cache
key: >- key: >-
@@ -886,7 +886,7 @@ jobs:
libturbojpeg \ libturbojpeg \
libgammu-dev libgammu-dev
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -895,7 +895,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -947,7 +947,7 @@ jobs:
libgammu-dev \ libgammu-dev \
libxml2-utils libxml2-utils
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -956,7 +956,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -970,7 +970,7 @@ jobs:
run: | run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets - name: Download pytest_buckets
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
name: pytest_buckets name: pytest_buckets
- name: Compile English translations - name: Compile English translations
@@ -1080,7 +1080,7 @@ jobs:
libmariadb-dev-compat \ libmariadb-dev-compat \
libxml2-utils libxml2-utils
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -1089,7 +1089,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1222,7 +1222,7 @@ jobs:
sudo apt-get -y install \ sudo apt-get -y install \
postgresql-server-dev-14 postgresql-server-dev-14
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -1231,7 +1231,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1334,9 +1334,9 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
pattern: coverage-* pattern: coverage-*
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
@@ -1381,7 +1381,7 @@ jobs:
libgammu-dev \ libgammu-dev \
libxml2-utils libxml2-utils
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0
@@ -1390,7 +1390,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.2.3 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1484,9 +1484,9 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
pattern: coverage-* pattern: coverage-*
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
@@ -1511,7 +1511,7 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
pattern: test-results-* pattern: test-results-*
- name: Upload test results to Codecov - name: Upload test results to Codecov

View File

@@ -21,14 +21,14 @@ jobs:
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.29.0 uses: github/codeql-action/init@v3.29.9
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.0 uses: github/codeql-action/analyze@v3.29.9
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI - name: Detect duplicates using AI
id: ai_detection id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@v1.1.0 uses: actions/ai-inference@v2.0.0
with: with:
model: openai/gpt-4o model: openai/gpt-4o
system-prompt: | system-prompt: |

View File

@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI - name: Detect language using AI
id: ai_language_detection id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true' if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@v1.1.0 uses: actions/ai-inference@v2.0.0
with: with:
model: openai/gpt-4o-mini model: openai/gpt-4o-mini
system-prompt: | system-prompt: |

View File

@@ -0,0 +1,84 @@
name: Restrict task creation
# yamllint disable-line rule:truthy
on:
issues:
types: [opened]
jobs:
check-authorization:
runs-on: ubuntu-latest
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@v7
with:
script: |
const issueAuthor = context.payload.issue.user.login;
// First check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: 'home-assistant',
username: issueAuthor
});
console.log(`✅ ${issueAuthor} is an organization member`);
return; // Authorized, no need to check further
} catch (error) {
console.log(` ${issueAuthor} is not an organization member, checking codeowners...`);
}
// If not an org member, check if they're a codeowner
try {
// Fetch CODEOWNERS file from the repository
const { data: codeownersFile } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: 'CODEOWNERS',
ref: 'dev'
});
// Decode the content (it's base64 encoded)
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf-8');
// Check if the issue author is mentioned in CODEOWNERS
// GitHub usernames in CODEOWNERS are prefixed with @
if (codeownersContent.includes(`@${issueAuthor}`)) {
console.log(`✅ ${issueAuthor} is a integration code owner`);
return; // Authorized
}
} catch (error) {
console.error('Error checking CODEOWNERS:', error);
}
// If we reach here, user is not authorized
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff, authorized contributors, and integration code owners.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['auto-closed']
});

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.6.0 uses: actions/setup-python@v5.6.0

View File

@@ -32,7 +32,7 @@ jobs:
architectures: ${{ steps.info.outputs.architectures }} architectures: ${{ steps.info.outputs.architectures }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
@@ -135,20 +135,20 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
name: env_file name: env_file
- name: Download build_constraints - name: Download build_constraints
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
name: build_constraints name: build_constraints
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
name: requirements_diff name: requirements_diff
@@ -159,7 +159,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt sed -i "/uv/d" requirements_diff.txt
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@2025.03.0 uses: home-assistant/wheels@2025.07.0
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@@ -184,25 +184,25 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v5.0.0
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
name: env_file name: env_file
- name: Download build_constraints - name: Download build_constraints
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
name: build_constraints name: build_constraints
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
name: requirements_diff name: requirements_diff
- name: Download requirements_all_wheels - name: Download requirements_all_wheels
uses: actions/download-artifact@v4.3.0 uses: actions/download-artifact@v5.0.0
with: with:
name: requirements_all_wheels name: requirements_all_wheels
@@ -219,7 +219,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt sed -i "/uv/d" requirements_diff.txt
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@2025.03.0 uses: home-assistant/wheels@2025.07.0
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2

6
.gitignore vendored
View File

@@ -137,4 +137,8 @@ tmp_cache
.ropeproject .ropeproject
# Will be created from script/split_tests.py # Will be created from script/split_tests.py
pytest_buckets.txt pytest_buckets.txt
# AI tooling
.claude

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.0 rev: v0.12.1
hooks: hooks:
- id: ruff-check - id: ruff-check
args: args:
@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html] exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v6.0.0
hooks: hooks:
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
stages: [manual] stages: [manual]

View File

@@ -53,6 +53,7 @@ homeassistant.components.air_quality.*
homeassistant.components.airgradient.* homeassistant.components.airgradient.*
homeassistant.components.airly.* homeassistant.components.airly.*
homeassistant.components.airnow.* homeassistant.components.airnow.*
homeassistant.components.airos.*
homeassistant.components.airq.* homeassistant.components.airq.*
homeassistant.components.airthings.* homeassistant.components.airthings.*
homeassistant.components.airthings_ble.* homeassistant.components.airthings_ble.*
@@ -309,7 +310,6 @@ homeassistant.components.letpot.*
homeassistant.components.lidarr.* homeassistant.components.lidarr.*
homeassistant.components.lifx.* homeassistant.components.lifx.*
homeassistant.components.light.* homeassistant.components.light.*
homeassistant.components.linear_garage_door.*
homeassistant.components.linkplay.* homeassistant.components.linkplay.*
homeassistant.components.litejet.* homeassistant.components.litejet.*
homeassistant.components.litterrobot.* homeassistant.components.litterrobot.*
@@ -377,10 +377,12 @@ homeassistant.components.onedrive.*
homeassistant.components.onewire.* homeassistant.components.onewire.*
homeassistant.components.onkyo.* homeassistant.components.onkyo.*
homeassistant.components.open_meteo.* homeassistant.components.open_meteo.*
homeassistant.components.open_router.*
homeassistant.components.openai_conversation.* homeassistant.components.openai_conversation.*
homeassistant.components.openexchangerates.* homeassistant.components.openexchangerates.*
homeassistant.components.opensky.* homeassistant.components.opensky.*
homeassistant.components.openuv.* homeassistant.components.openuv.*
homeassistant.components.opower.*
homeassistant.components.oralb.* homeassistant.components.oralb.*
homeassistant.components.otbr.* homeassistant.components.otbr.*
homeassistant.components.overkiz.* homeassistant.components.overkiz.*
@@ -464,6 +466,7 @@ homeassistant.components.simplisafe.*
homeassistant.components.siren.* homeassistant.components.siren.*
homeassistant.components.skybell.* homeassistant.components.skybell.*
homeassistant.components.slack.* homeassistant.components.slack.*
homeassistant.components.sleep_as_android.*
homeassistant.components.sleepiq.* homeassistant.components.sleepiq.*
homeassistant.components.smhi.* homeassistant.components.smhi.*
homeassistant.components.smlight.* homeassistant.components.smlight.*
@@ -499,6 +502,7 @@ homeassistant.components.tag.*
homeassistant.components.tailscale.* homeassistant.components.tailscale.*
homeassistant.components.tailwind.* homeassistant.components.tailwind.*
homeassistant.components.tami4.* homeassistant.components.tami4.*
homeassistant.components.tankerkoenig.*
homeassistant.components.tautulli.* homeassistant.components.tautulli.*
homeassistant.components.tcp.* homeassistant.components.tcp.*
homeassistant.components.technove.* homeassistant.components.technove.*
@@ -534,6 +538,7 @@ homeassistant.components.unifiprotect.*
homeassistant.components.upcloud.* homeassistant.components.upcloud.*
homeassistant.components.update.* homeassistant.components.update.*
homeassistant.components.uptime.* homeassistant.components.uptime.*
homeassistant.components.uptime_kuma.*
homeassistant.components.uptimerobot.* homeassistant.components.uptimerobot.*
homeassistant.components.usb.* homeassistant.components.usb.*
homeassistant.components.uvc.* homeassistant.components.uvc.*
@@ -543,6 +548,7 @@ homeassistant.components.valve.*
homeassistant.components.velbus.* homeassistant.components.velbus.*
homeassistant.components.vlc_telnet.* homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.* homeassistant.components.vodafone_station.*
homeassistant.components.volvo.*
homeassistant.components.wake_on_lan.* homeassistant.components.wake_on_lan.*
homeassistant.components.wake_word.* homeassistant.components.wake_word.*
homeassistant.components.wallbox.* homeassistant.components.wallbox.*

1
CLAUDE.md Symbolic link
View File

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

44
CODEOWNERS generated
View File

@@ -67,6 +67,8 @@ build.json @home-assistant/supervisor
/tests/components/airly/ @bieniu /tests/components/airly/ @bieniu
/homeassistant/components/airnow/ @asymworks /homeassistant/components/airnow/ @asymworks
/tests/components/airnow/ @asymworks /tests/components/airnow/ @asymworks
/homeassistant/components/airos/ @CoMPaTech
/tests/components/airos/ @CoMPaTech
/homeassistant/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airq/ @Sibgatulin @dl2080
/tests/components/airq/ @Sibgatulin @dl2080 /tests/components/airq/ @Sibgatulin @dl2080
/homeassistant/components/airthings/ @danielhiversen @LaStrada /homeassistant/components/airthings/ @danielhiversen @LaStrada
@@ -154,8 +156,8 @@ build.json @home-assistant/supervisor
/tests/components/assist_pipeline/ @balloob @synesthesiam /tests/components/assist_pipeline/ @balloob @synesthesiam
/homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam /homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam
/tests/components/assist_satellite/ @home-assistant/core @synesthesiam /tests/components/assist_satellite/ @home-assistant/core @synesthesiam
/homeassistant/components/asuswrt/ @kennedyshead @ollo69 /homeassistant/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi
/tests/components/asuswrt/ @kennedyshead @ollo69 /tests/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi
/homeassistant/components/atag/ @MatsNL /homeassistant/components/atag/ @MatsNL
/tests/components/atag/ @MatsNL /tests/components/atag/ @MatsNL
/homeassistant/components/aten_pe/ @mtdcr /homeassistant/components/aten_pe/ @mtdcr
@@ -420,6 +422,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/emby/ @mezz64 /homeassistant/components/emby/ @mezz64
/homeassistant/components/emoncms/ @borpin @alexandrecuer /homeassistant/components/emoncms/ @borpin @alexandrecuer
/tests/components/emoncms/ @borpin @alexandrecuer /tests/components/emoncms/ @borpin @alexandrecuer
/homeassistant/components/emoncms_history/ @alexandrecuer
/tests/components/emoncms_history/ @alexandrecuer
/homeassistant/components/emonitor/ @bdraco /homeassistant/components/emonitor/ @bdraco
/tests/components/emonitor/ @bdraco /tests/components/emonitor/ @bdraco
/homeassistant/components/emulated_hue/ @bdraco @Tho85 /homeassistant/components/emulated_hue/ @bdraco @Tho85
@@ -436,8 +440,8 @@ build.json @home-assistant/supervisor
/tests/components/enigma2/ @autinerd /tests/components/enigma2/ @autinerd
/homeassistant/components/enocean/ @bdurrer /homeassistant/components/enocean/ @bdurrer
/tests/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac /tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie
@@ -452,8 +456,8 @@ build.json @home-assistant/supervisor
/tests/components/eq3btsmart/ @eulemitkeule @dbuezas /tests/components/eq3btsmart/ @eulemitkeule @dbuezas
/homeassistant/components/escea/ @lazdavila /homeassistant/components/escea/ @lazdavila
/tests/components/escea/ @lazdavila /tests/components/escea/ @lazdavila
/homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco /homeassistant/components/esphome/ @jesserockz @kbx81 @bdraco
/tests/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco /tests/components/esphome/ @jesserockz @kbx81 @bdraco
/homeassistant/components/eufylife_ble/ @bdr99 /homeassistant/components/eufylife_ble/ @bdr99
/tests/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99
/homeassistant/components/event/ @home-assistant/core /homeassistant/components/event/ @home-assistant/core
@@ -684,8 +688,8 @@ build.json @home-assistant/supervisor
/tests/components/husqvarna_automower/ @Thomas55555 /tests/components/husqvarna_automower/ @Thomas55555
/homeassistant/components/husqvarna_automower_ble/ @alistair23 /homeassistant/components/husqvarna_automower_ble/ @alistair23
/tests/components/husqvarna_automower_ble/ @alistair23 /tests/components/husqvarna_automower_ble/ @alistair23
/homeassistant/components/huum/ @frwickst /homeassistant/components/huum/ @frwickst @vincentwolsink
/tests/components/huum/ @frwickst /tests/components/huum/ @frwickst @vincentwolsink
/homeassistant/components/hvv_departures/ @vigonotion /homeassistant/components/hvv_departures/ @vigonotion
/tests/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion
/homeassistant/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan /homeassistant/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan
@@ -860,8 +864,6 @@ build.json @home-assistant/supervisor
/tests/components/lifx/ @Djelibeybi /tests/components/lifx/ @Djelibeybi
/homeassistant/components/light/ @home-assistant/core /homeassistant/components/light/ @home-assistant/core
/tests/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core
/homeassistant/components/linear_garage_door/ @IceBotYT
/tests/components/linear_garage_door/ @IceBotYT
/homeassistant/components/linkplay/ @Velleman /homeassistant/components/linkplay/ @Velleman
/tests/components/linkplay/ @Velleman /tests/components/linkplay/ @Velleman
/homeassistant/components/linux_battery/ @fabaff /homeassistant/components/linux_battery/ @fabaff
@@ -1102,6 +1104,8 @@ build.json @home-assistant/supervisor
/tests/components/onvif/ @hunterjm @jterrace /tests/components/onvif/ @hunterjm @jterrace
/homeassistant/components/open_meteo/ @frenck /homeassistant/components/open_meteo/ @frenck
/tests/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek
/tests/components/open_router/ @joostlek
/homeassistant/components/openai_conversation/ @balloob /homeassistant/components/openai_conversation/ @balloob
/tests/components/openai_conversation/ @balloob /tests/components/openai_conversation/ @balloob
/homeassistant/components/openerz/ @misialq /homeassistant/components/openerz/ @misialq
@@ -1169,8 +1173,8 @@ build.json @home-assistant/supervisor
/tests/components/ping/ @jpbede /tests/components/ping/ @jpbede
/homeassistant/components/plaato/ @JohNan /homeassistant/components/plaato/ @JohNan
/tests/components/plaato/ @JohNan /tests/components/plaato/ @JohNan
/homeassistant/components/playstation_network/ @jackjpowell /homeassistant/components/playstation_network/ @jackjpowell @tr4nt0r
/tests/components/playstation_network/ @jackjpowell /tests/components/playstation_network/ @jackjpowell @tr4nt0r
/homeassistant/components/plex/ @jjlawren /homeassistant/components/plex/ @jjlawren
/tests/components/plex/ @jjlawren /tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew /homeassistant/components/plugwise/ @CoMPaTech @bouwew
@@ -1413,6 +1417,8 @@ build.json @home-assistant/supervisor
/tests/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob
/homeassistant/components/slack/ @tkdrob @fletcherau /homeassistant/components/slack/ @tkdrob @fletcherau
/tests/components/slack/ @tkdrob @fletcherau /tests/components/slack/ @tkdrob @fletcherau
/homeassistant/components/sleep_as_android/ @tr4nt0r
/tests/components/sleep_as_android/ @tr4nt0r
/homeassistant/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/sleepiq/ @mfugate1 @kbickar
/tests/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar
/homeassistant/components/slide/ @ualex73 /homeassistant/components/slide/ @ualex73
@@ -1553,6 +1559,8 @@ build.json @home-assistant/supervisor
/tests/components/technove/ @Moustachauve /tests/components/technove/ @Moustachauve
/homeassistant/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tedee/ @patrickhilker @zweckj
/tests/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/telegram_bot/ @hanwg
/tests/components/telegram_bot/ @hanwg
/homeassistant/components/tellduslive/ @fredrike /homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @Petro31 @home-assistant/core /homeassistant/components/template/ @Petro31 @home-assistant/core
@@ -1593,6 +1601,8 @@ build.json @home-assistant/supervisor
/tests/components/todo/ @home-assistant/core /tests/components/todo/ @home-assistant/core
/homeassistant/components/todoist/ @boralyl /homeassistant/components/todoist/ @boralyl
/tests/components/todoist/ @boralyl /tests/components/todoist/ @boralyl
/homeassistant/components/togrill/ @elupus
/tests/components/togrill/ @elupus
/homeassistant/components/tolo/ @MatthiasLohr /homeassistant/components/tolo/ @MatthiasLohr
/tests/components/tolo/ @MatthiasLohr /tests/components/tolo/ @MatthiasLohr
/homeassistant/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/tomorrowio/ @raman325 @lymanepp
@@ -1607,8 +1617,6 @@ build.json @home-assistant/supervisor
/tests/components/tplink_omada/ @MarkGodwin /tests/components/tplink_omada/ @MarkGodwin
/homeassistant/components/traccar/ @ludeeus /homeassistant/components/traccar/ @ludeeus
/tests/components/traccar/ @ludeeus /tests/components/traccar/ @ludeeus
/homeassistant/components/traccar_server/ @ludeeus
/tests/components/traccar_server/ @ludeeus
/homeassistant/components/trace/ @home-assistant/core /homeassistant/components/trace/ @home-assistant/core
/tests/components/trace/ @home-assistant/core /tests/components/trace/ @home-assistant/core
/homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu /homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu
@@ -1656,6 +1664,8 @@ build.json @home-assistant/supervisor
/tests/components/upnp/ @StevenLooman /tests/components/upnp/ @StevenLooman
/homeassistant/components/uptime/ @frenck /homeassistant/components/uptime/ @frenck
/tests/components/uptime/ @frenck /tests/components/uptime/ @frenck
/homeassistant/components/uptime_kuma/ @tr4nt0r
/tests/components/uptime_kuma/ @tr4nt0r
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74 /homeassistant/components/uptimerobot/ @ludeeus @chemelli74
/tests/components/uptimerobot/ @ludeeus @chemelli74 /tests/components/uptimerobot/ @ludeeus @chemelli74
/homeassistant/components/usb/ @bdraco /homeassistant/components/usb/ @bdraco
@@ -1700,6 +1710,8 @@ build.json @home-assistant/supervisor
/tests/components/voip/ @balloob @synesthesiam @jaminh /tests/components/voip/ @balloob @synesthesiam @jaminh
/homeassistant/components/volumio/ @OnFreund /homeassistant/components/volumio/ @OnFreund
/tests/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund
/homeassistant/components/volvo/ @thomasddn
/tests/components/volvo/ @thomasddn
/homeassistant/components/volvooncall/ @molobrakos /homeassistant/components/volvooncall/ @molobrakos
/tests/components/volvooncall/ @molobrakos /tests/components/volvooncall/ @molobrakos
/homeassistant/components/vulcan/ @Antoni-Czaplicki /homeassistant/components/vulcan/ @Antoni-Czaplicki
@@ -1754,8 +1766,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/wirelesstag/ @sergeymaysak /homeassistant/components/wirelesstag/ @sergeymaysak
/homeassistant/components/withings/ @joostlek /homeassistant/components/withings/ @joostlek
/tests/components/withings/ @joostlek /tests/components/withings/ @joostlek
/homeassistant/components/wiz/ @sbidy /homeassistant/components/wiz/ @sbidy @arturpragacz
/tests/components/wiz/ @sbidy /tests/components/wiz/ @sbidy @arturpragacz
/homeassistant/components/wled/ @frenck /homeassistant/components/wled/ @frenck
/tests/components/wled/ @frenck /tests/components/wled/ @frenck
/homeassistant/components/wmspro/ @mback2k /homeassistant/components/wmspro/ @mback2k

View File

@@ -14,5 +14,8 @@ Still interested? Then you should take a peek at the [developer documentation](h
## Feature suggestions ## Feature suggestions
If you want to suggest a new feature for Home Assistant (e.g., new integrations), please open a thread in our [Community Forum: Feature Requests](https://community.home-assistant.io/c/feature-requests). If you want to suggest a new feature for Home Assistant (e.g. new integrations), please [start a discussion](https://github.com/orgs/home-assistant/discussions) on GitHub.
We use [GitHub for tracking issues](https://github.com/home-assistant/core/issues), not for tracking feature requests.
## Issue Tracker
If you want to report an issue, please [create an issue](https://github.com/home-assistant/core/issues) on GitHub.

2
Dockerfile generated
View File

@@ -31,7 +31,7 @@ RUN \
&& go2rtc --version && go2rtc --version
# Install uv # Install uv
RUN pip3 install uv==0.7.1 RUN pip3 install uv==0.8.9
WORKDIR /usr/src WORKDIR /usr/src

View File

@@ -1,15 +1,7 @@
FROM mcr.microsoft.com/devcontainers/python:1-3.13 FROM mcr.microsoft.com/vscode/devcontainers/base:debian
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Uninstall pre-installed formatting and linting tools
# They would conflict with our pinned versions
RUN \
pipx uninstall pydocstyle \
&& pipx uninstall pycodestyle \
&& pipx uninstall mypy \
&& pipx uninstall pylint
RUN \ RUN \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& apt-get update \ && apt-get update \
@@ -32,21 +24,18 @@ RUN \
libxml2 \ libxml2 \
git \ git \
cmake \ cmake \
autoconf \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Add go2rtc binary # Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc
# Install uv
RUN pip3 install uv
WORKDIR /usr/src WORKDIR /usr/src
# Setup hass-release COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN git clone --depth 1 https://github.com/home-assistant/hass-release \
&& uv pip install --system -e hass-release/ \ RUN uv python install 3.13.2
&& chown -R vscode /usr/src/hass-release/data
USER vscode USER vscode
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
@@ -55,6 +44,10 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /tmp WORKDIR /tmp
# Setup hass-release
RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \
&& uv pip install -e ~/hass-release/
# Install Python dependencies from requirements # Install Python dependencies from requirements
COPY requirements.txt ./ COPY requirements.txt ./
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
@@ -65,4 +58,4 @@ RUN uv pip install -r requirements_test.txt
WORKDIR /workspaces WORKDIR /workspaces
# Set the default shell to bash instead of sh # Set the default shell to bash instead of sh
ENV SHELL /bin/bash ENV SHELL=/bin/bash

View File

@@ -120,6 +120,9 @@ class AuthStore:
new_user = models.User(**kwargs) new_user = models.User(**kwargs)
while new_user.id in self._users:
new_user = models.User(**kwargs)
self._users[new_user.id] = new_user self._users[new_user.id] = new_user
if credentials is None: if credentials is None:

View File

@@ -33,7 +33,10 @@ class AuthFlowContext(FlowContext, total=False):
redirect_uri: str redirect_uri: str
AuthFlowResult = FlowResult[AuthFlowContext, tuple[str, str]] class AuthFlowResult(FlowResult[AuthFlowContext, tuple[str, str]], total=False):
"""Typed result dict for auth flow."""
result: Credentials # Only present if type is CREATE_ENTRY
@attr.s(slots=True) @attr.s(slots=True)

View File

@@ -75,8 +75,8 @@ from .core_config import async_process_ha_core_config
from .exceptions import HomeAssistantError from .exceptions import HomeAssistantError
from .helpers import ( from .helpers import (
area_registry, area_registry,
backup,
category_registry, category_registry,
condition,
config_validation as cv, config_validation as cv,
device_registry, device_registry,
entity, entity,
@@ -89,6 +89,7 @@ from .helpers import (
restore_state, restore_state,
template, template,
translation, translation,
trigger,
) )
from .helpers.dispatcher import async_dispatcher_send_internal from .helpers.dispatcher import async_dispatcher_send_internal
from .helpers.storage import get_internal_store_manager from .helpers.storage import get_internal_store_manager
@@ -331,6 +332,9 @@ async def async_setup_hass(
if not is_virtual_env(): if not is_virtual_env():
await async_mount_local_lib_path(runtime_config.config_dir) await async_mount_local_lib_path(runtime_config.config_dir)
if hass.config.safe_mode:
_LOGGER.info("Starting in safe mode")
basic_setup_success = ( basic_setup_success = (
await async_from_config_dict(config_dict, hass) is not None await async_from_config_dict(config_dict, hass) is not None
) )
@@ -383,8 +387,6 @@ async def async_setup_hass(
{"recovery_mode": {}, "http": http_conf}, {"recovery_mode": {}, "http": http_conf},
hass, hass,
) )
elif hass.config.safe_mode:
_LOGGER.info("Starting in safe mode")
if runtime_config.open_ui: if runtime_config.open_ui:
hass.add_job(open_hass_ui, hass) hass.add_job(open_hass_ui, hass)
@@ -452,6 +454,8 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
create_eager_task(restore_state.async_load(hass)), create_eager_task(restore_state.async_load(hass)),
create_eager_task(hass.config_entries.async_initialize()), create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)), create_eager_task(async_get_system_info(hass)),
create_eager_task(condition.async_setup(hass)),
create_eager_task(trigger.async_setup(hass)),
) )
@@ -605,7 +609,7 @@ async def async_enable_logging(
) )
threading.excepthook = lambda args: logging.getLogger().exception( threading.excepthook = lambda args: logging.getLogger().exception(
"Uncaught thread exception", "Uncaught thread exception",
exc_info=( # type: ignore[arg-type] # noqa: LOG014 exc_info=( # type: ignore[arg-type]
args.exc_type, args.exc_type,
args.exc_value, args.exc_value,
args.exc_traceback, args.exc_traceback,
@@ -691,10 +695,10 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
"""Get domains of components to set up.""" """Get domains of components to set up."""
# Filter out the repeating and common config section [homeassistant] # The common config section [homeassistant] could be filtered here,
domains = { # but that is not necessary, since it corresponds to the core integration,
domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN # that is always unconditionally loaded.
} domains = {cv.domain_key(key) for key in config}
# Add config entry and default domains # Add config entry and default domains
if not hass.config.recovery_mode: if not hass.config.recovery_mode:
@@ -722,34 +726,28 @@ async def _async_resolve_domains_and_preload(
together with all their dependencies. together with all their dependencies.
""" """
domains_to_setup = _get_domains(hass, config) domains_to_setup = _get_domains(hass, config)
platform_integrations = conf_util.extract_platform_integrations(
config, BASE_PLATFORMS # Also process all base platforms since we do not require the manifest
) # to list them as dependencies.
# Ensure base platforms that have platform integrations are added to `domains`, # We want to later avoid lock contention when multiple integrations try to load
# so they can be setup first instead of discovering them later when a config # their manifests at once.
# entry setup task notices that it's needed and there is already a long line
# to use the import executor.
# #
# Additionally process integrations that are defined under base platforms
# to speed things up.
# For example if we have # For example if we have
# sensor: # sensor:
# - platform: template # - platform: template
# #
# `template` has to be loaded to validate the config for sensor # `template` has to be loaded to validate the config for sensor.
# so we want to start loading `sensor` as soon as we know # The more platforms under `sensor:`, the longer
# it will be needed. The more platforms under `sensor:`, the longer
# it will take to finish setup for `sensor` because each of these # it will take to finish setup for `sensor` because each of these
# platforms has to be imported before we can validate the config. # platforms has to be imported before we can validate the config.
# #
# Thankfully we are migrating away from the platform pattern # Thankfully we are migrating away from the platform pattern
# so this will be less of a problem in the future. # so this will be less of a problem in the future.
domains_to_setup.update(platform_integrations) platform_integrations = conf_util.extract_platform_integrations(
config, BASE_PLATFORMS
# Additionally process base platforms since we do not require the manifest )
# to list them as dependencies.
# We want to later avoid lock contention when multiple integrations try to load
# their manifests at once.
# Also process integrations that are defined under base platforms
# to speed things up.
additional_domains_to_process = { additional_domains_to_process = {
*BASE_PLATFORMS, *BASE_PLATFORMS,
*chain.from_iterable(platform_integrations.values()), *chain.from_iterable(platform_integrations.values()),
@@ -867,9 +865,9 @@ async def _async_set_up_integrations(
domains = set(integrations) & all_domains domains = set(integrations) & all_domains
_LOGGER.info( _LOGGER.info(
"Domains to be set up: %s | %s", "Domains to be set up: %s\nDependencies: %s",
domains, domains or "{}",
all_domains - domains, (all_domains - domains) or "{}",
) )
async_set_domains_to_be_loaded(hass, all_domains) async_set_domains_to_be_loaded(hass, all_domains)
@@ -878,10 +876,6 @@ async def _async_set_up_integrations(
if "recorder" in all_domains: if "recorder" in all_domains:
recorder.async_initialize_recorder(hass) recorder.async_initialize_recorder(hass)
# Initialize backup
if "backup" in all_domains:
backup.async_initialize_backup(hass)
stages: list[tuple[str, set[str], int | None]] = [ stages: list[tuple[str, set[str], int | None]] = [
*( *(
(name, domain_group, timeout) (name, domain_group, timeout)
@@ -914,12 +908,13 @@ async def _async_set_up_integrations(
stage_all_domains = stage_domains | stage_dep_domains stage_all_domains = stage_domains | stage_dep_domains
_LOGGER.info( _LOGGER.info(
"Setting up stage %s: %s | %s\nDependencies: %s | %s", "Setting up stage %s: %s; already set up: %s\n"
"Dependencies: %s; already set up: %s",
name, name,
stage_domains, stage_domains,
stage_domains_unfiltered - stage_domains, (stage_domains_unfiltered - stage_domains) or "{}",
stage_dep_domains, stage_dep_domains or "{}",
stage_dep_domains_unfiltered - stage_dep_domains, (stage_dep_domains_unfiltered - stage_dep_domains) or "{}",
) )
if timeout is None: if timeout is None:
@@ -1059,5 +1054,5 @@ async def _async_setup_multi_components(
_LOGGER.error( _LOGGER.error(
"Error setting up integration %s - received exception", "Error setting up integration %s - received exception",
domain, domain,
exc_info=(type(result), result, result.__traceback__), # noqa: LOG014 exc_info=(type(result), result, result.__traceback__),
) )

View File

@@ -0,0 +1,5 @@
{
"domain": "frient",
"name": "Frient",
"iot_standards": ["zigbee"]
}

View File

@@ -1,5 +1,5 @@
{ {
"domain": "third_reality", "domain": "third_reality",
"name": "Third Reality", "name": "Third Reality",
"iot_standards": ["zigbee"] "iot_standards": ["matter", "zigbee"]
} }

View File

@@ -1,5 +1,5 @@
{ {
"domain": "ubiquiti", "domain": "ubiquiti",
"name": "Ubiquiti", "name": "Ubiquiti",
"integrations": ["unifi", "unifi_direct", "unifiled", "unifiprotect"] "integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
} }

View File

@@ -336,7 +336,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
keys=[AOD_WEATHER, AOD_WIND_DIRECTION], keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
name="Wind bearing", name="Wind bearing",
native_unit_of_measurement=DEGREE, native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT_ANGLE,
device_class=SensorDeviceClass.WIND_DIRECTION, device_class=SensorDeviceClass.WIND_DIRECTION,
), ),
AemetSensorEntityDescription( AemetSensorEntityDescription(

View File

@@ -1,11 +1,12 @@
"""Integration to offer AI tasks to Home Assistant.""" """Integration to offer AI tasks to Home Assistant."""
import logging import logging
from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR
from homeassistant.core import ( from homeassistant.core import (
HassJobType, HassJobType,
HomeAssistant, HomeAssistant,
@@ -14,12 +15,15 @@ from homeassistant.core import (
SupportsResponse, SupportsResponse,
callback, callback,
) )
from homeassistant.helpers import config_validation as cv, storage from homeassistant.helpers import config_validation as cv, selector, storage
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from .const import ( from .const import (
ATTR_ATTACHMENTS,
ATTR_INSTRUCTIONS, ATTR_INSTRUCTIONS,
ATTR_REQUIRED,
ATTR_STRUCTURE,
ATTR_TASK_NAME, ATTR_TASK_NAME,
DATA_COMPONENT, DATA_COMPONENT,
DATA_PREFERENCES, DATA_PREFERENCES,
@@ -47,6 +51,27 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
STRUCTURE_FIELD_SCHEMA = vol.Schema(
{
vol.Optional(CONF_DESCRIPTION): str,
vol.Optional(ATTR_REQUIRED): bool,
vol.Required(CONF_SELECTOR): selector.validate_selector,
}
)
def _validate_structure_fields(value: dict[str, Any]) -> vol.Schema:
"""Validate the structure fields as a voluptuous Schema."""
if not isinstance(value, dict):
raise vol.Invalid("Structure must be a dictionary")
fields = {}
for k, v in value.items():
field_class = vol.Required if v.get(ATTR_REQUIRED, False) else vol.Optional
fields[field_class(k, description=v.get(CONF_DESCRIPTION))] = selector.selector(
v[CONF_SELECTOR]
)
return vol.Schema(fields, extra=vol.PREVENT_EXTRA)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the process service.""" """Register the process service."""
@@ -64,6 +89,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Required(ATTR_TASK_NAME): cv.string, vol.Required(ATTR_TASK_NAME): cv.string,
vol.Optional(ATTR_ENTITY_ID): cv.entity_id, vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_INSTRUCTIONS): cv.string, vol.Required(ATTR_INSTRUCTIONS): cv.string,
vol.Optional(ATTR_STRUCTURE): vol.All(
vol.Schema({str: STRUCTURE_FIELD_SCHEMA}),
_validate_structure_fields,
),
vol.Optional(ATTR_ATTACHMENTS): vol.All(
cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
),
} }
), ),
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,

View File

@@ -21,6 +21,9 @@ SERVICE_GENERATE_DATA = "generate_data"
ATTR_INSTRUCTIONS: Final = "instructions" ATTR_INSTRUCTIONS: Final = "instructions"
ATTR_TASK_NAME: Final = "task_name" ATTR_TASK_NAME: Final = "task_name"
ATTR_STRUCTURE: Final = "structure"
ATTR_REQUIRED: Final = "required"
ATTR_ATTACHMENTS: Final = "attachments"
DEFAULT_SYSTEM_PROMPT = ( DEFAULT_SYSTEM_PROMPT = (
"You are a Home Assistant expert and help users with their tasks." "You are a Home Assistant expert and help users with their tasks."
@@ -32,3 +35,6 @@ class AITaskEntityFeature(IntFlag):
GENERATE_DATA = 1 GENERATE_DATA = 1
"""Generate data based on instructions.""" """Generate data based on instructions."""
SUPPORT_ATTACHMENTS = 2
"""Support attachments with generate data."""

View File

@@ -13,7 +13,7 @@ from homeassistant.components.conversation import (
) )
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.helpers import llm from homeassistant.helpers import llm
from homeassistant.helpers.chat_session import async_get_chat_session from homeassistant.helpers.chat_session import ChatSession
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@@ -56,12 +56,12 @@ class AITaskEntity(RestoreEntity):
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def _async_get_ai_task_chat_log( async def _async_get_ai_task_chat_log(
self, self,
session: ChatSession,
task: GenDataTask, task: GenDataTask,
) -> AsyncGenerator[ChatLog]: ) -> AsyncGenerator[ChatLog]:
"""Context manager used to manage the ChatLog used during an AI Task.""" """Context manager used to manage the ChatLog used during an AI Task."""
# pylint: disable-next=contextmanager-generator-missing-cleanup # pylint: disable-next=contextmanager-generator-missing-cleanup
with ( with (
async_get_chat_session(self.hass) as session,
async_get_chat_log( async_get_chat_log(
self.hass, self.hass,
session, session,
@@ -79,19 +79,22 @@ class AITaskEntity(RestoreEntity):
user_llm_prompt=DEFAULT_SYSTEM_PROMPT, user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
) )
chat_log.async_add_user_content(UserContent(task.instructions)) chat_log.async_add_user_content(
UserContent(task.instructions, attachments=task.attachments)
)
yield chat_log yield chat_log
@final @final
async def internal_async_generate_data( async def internal_async_generate_data(
self, self,
session: ChatSession,
task: GenDataTask, task: GenDataTask,
) -> GenDataTaskResult: ) -> GenDataTaskResult:
"""Run a gen data task.""" """Run a gen data task."""
self.__last_activity = dt_util.utcnow().isoformat() self.__last_activity = dt_util.utcnow().isoformat()
self.async_write_ha_state() self.async_write_ha_state()
async with self._async_get_ai_task_chat_log(task) as chat_log: async with self._async_get_ai_task_chat_log(session, task) as chat_log:
return await self._async_generate_data(task, chat_log) return await self._async_generate_data(task, chat_log)
async def _async_generate_data( async def _async_generate_data(

View File

@@ -1,8 +1,9 @@
{ {
"domain": "ai_task", "domain": "ai_task",
"name": "AI Task", "name": "AI Task",
"after_dependencies": ["camera"],
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"dependencies": ["conversation"], "dependencies": ["conversation", "media_source"],
"documentation": "https://www.home-assistant.io/integrations/ai_task", "documentation": "https://www.home-assistant.io/integrations/ai_task",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal" "quality_scale": "internal"

View File

@@ -10,10 +10,24 @@ generate_data:
required: true required: true
selector: selector:
text: text:
multiline: true
entity_id: entity_id:
required: false required: false
selector: selector:
entity: entity:
domain: ai_task filter:
supported_features: domain: ai_task
- ai_task.AITaskEntityFeature.GENERATE_DATA supported_features:
- ai_task.AITaskEntityFeature.GENERATE_DATA
structure:
advanced: true
required: false
example: '{ "name": { "selector": { "text": }, "description": "Name of the user", "required": "True" } } }, "age": { "selector": { "number": }, "description": "Age of the user" } }'
selector:
object:
attachments:
required: false
selector:
media:
accept:
- "*"

View File

@@ -15,6 +15,14 @@
"entity_id": { "entity_id": {
"name": "Entity ID", "name": "Entity ID",
"description": "Entity ID to run the task on. If not provided, the preferred entity will be used." "description": "Entity ID to run the task on. If not provided, the preferred entity will be used."
},
"structure": {
"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."
},
"attachments": {
"name": "Attachments",
"description": "List of files to attach for multi-modal AI analysis."
} }
} }
} }

View File

@@ -3,20 +3,40 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import mimetypes
from pathlib import Path
import tempfile
from typing import Any from typing import Any
from homeassistant.core import HomeAssistant import voluptuous as vol
from homeassistant.components import camera, conversation, media_source
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.chat_session import async_get_chat_session
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
def _save_camera_snapshot(image: camera.Image) -> Path:
"""Save camera snapshot to temp file."""
with tempfile.NamedTemporaryFile(
mode="wb",
suffix=mimetypes.guess_extension(image.content_type, False),
delete=False,
) as temp_file:
temp_file.write(image.content)
return Path(temp_file.name)
async def async_generate_data( async def async_generate_data(
hass: HomeAssistant, hass: HomeAssistant,
*, *,
task_name: str, task_name: str,
entity_id: str | None = None, entity_id: str | None = None,
instructions: str, instructions: str,
structure: vol.Schema | None = None,
attachments: list[dict] | None = None,
) -> GenDataTaskResult: ) -> GenDataTaskResult:
"""Run a task in the AI Task integration.""" """Run a task in the AI Task integration."""
if entity_id is None: if entity_id is None:
@@ -34,12 +54,80 @@ async def async_generate_data(
f"AI Task entity {entity_id} does not support generating data" f"AI Task entity {entity_id} does not support generating data"
) )
return await entity.internal_async_generate_data( # Resolve attachments
GenDataTask( resolved_attachments: list[conversation.Attachment] = []
name=task_name, created_files: list[Path] = []
instructions=instructions,
if (
attachments
and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
):
raise HomeAssistantError(
f"AI Task entity {entity_id} does not support attachments"
)
for attachment in attachments or []:
media_content_id = attachment["media_content_id"]
# Special case for camera media sources
if media_content_id.startswith("media-source://camera/"):
# Extract entity_id from the media content ID
entity_id = media_content_id.removeprefix("media-source://camera/")
# Get snapshot from camera
image = await camera.async_get_image(hass, entity_id)
temp_filename = await hass.async_add_executor_job(
_save_camera_snapshot, image
)
created_files.append(temp_filename)
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=image.content_type,
path=temp_filename,
)
)
else:
# Handle regular media sources
media = await media_source.async_resolve_media(hass, media_content_id, None)
if media.path is None:
raise HomeAssistantError(
"Only local attachments are currently supported"
)
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=media.mime_type,
path=media.path,
)
)
with async_get_chat_session(hass) as session:
if created_files:
def cleanup_files() -> None:
"""Cleanup temporary files."""
for file in created_files:
file.unlink(missing_ok=True)
@callback
def cleanup_files_callback() -> None:
"""Cleanup temporary files."""
hass.async_add_executor_job(cleanup_files)
session.async_on_cleanup(cleanup_files_callback)
return await entity.internal_async_generate_data(
session,
GenDataTask(
name=task_name,
instructions=instructions,
structure=structure,
attachments=resolved_attachments or None,
),
) )
)
@dataclass(slots=True) @dataclass(slots=True)
@@ -52,6 +140,12 @@ class GenDataTask:
instructions: str instructions: str
"""Instructions on what needs to be done.""" """Instructions on what needs to be done."""
structure: vol.Schema | None = None
"""Optional structure for the data to be generated."""
attachments: list[conversation.Attachment] | None = None
"""List of attachments to go along the instructions."""
def __str__(self) -> str: def __str__(self) -> str:
"""Return task as a string.""" """Return task as a string."""
return f"<GenDataTask {self.name}: {id(self)}>" return f"<GenDataTask {self.name}: {id(self)}>"

View File

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

View File

@@ -14,9 +14,9 @@ rules:
status: exempt status: exempt
comment: | comment: |
This integration does not provide additional actions. This integration does not provide additional actions.
docs-high-level-description: todo docs-high-level-description: done
docs-installation-instructions: todo docs-installation-instructions: done
docs-removal-instructions: todo docs-removal-instructions: done
entity-event-setup: entity-event-setup:
status: exempt status: exempt
comment: | comment: |
@@ -34,7 +34,7 @@ rules:
docs-configuration-parameters: docs-configuration-parameters:
status: exempt status: exempt
comment: No options to configure comment: No options to configure
docs-installation-parameters: todo docs-installation-parameters: done
entity-unavailable: done entity-unavailable: done
integration-owner: done integration-owner: done
log-when-unavailable: done log-when-unavailable: done
@@ -43,23 +43,19 @@ rules:
status: exempt status: exempt
comment: | comment: |
This integration does not require authentication. This integration does not require authentication.
test-coverage: todo test-coverage: done
# Gold # Gold
devices: done devices: done
diagnostics: done diagnostics: done
discovery-update-info: discovery-update-info: done
status: todo discovery: done
comment: DHCP is still possible docs-data-update: done
discovery: docs-examples: done
status: todo docs-known-limitations: done
comment: DHCP is still possible docs-supported-devices: done
docs-data-update: todo docs-supported-functions: done
docs-examples: todo docs-troubleshooting: done
docs-known-limitations: todo docs-use-cases: done
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: dynamic-devices:
status: exempt status: exempt
comment: | comment: |

View File

@@ -61,7 +61,7 @@
"display_pm_standard": { "display_pm_standard": {
"name": "Display PM standard", "name": "Display PM standard",
"state": { "state": {
"ugm3": "µg/m³", "ugm3": "μg/m³",
"us_aqi": "US AQI" "us_aqi": "US AQI"
} }
}, },

View File

@@ -45,9 +45,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bo
# Store Entity and Initialize Platforms # Store Entity and Initialize Platforms
entry.runtime_data = coordinator entry.runtime_data = coordinator
# Listen for option changes
entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Clean up unused device entries with no entities # Clean up unused device entries with no entities
@@ -88,8 +85,3 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> 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)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -13,7 +13,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlowWithReload,
) )
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@@ -126,7 +126,7 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN):
return AirNowOptionsFlowHandler() return AirNowOptionsFlowHandler()
class AirNowOptionsFlowHandler(OptionsFlow): class AirNowOptionsFlowHandler(OptionsFlowWithReload):
"""Handle an options flow for AirNow.""" """Handle an options flow for AirNow."""
async def async_step_init( async def async_step_init(

View File

@@ -71,7 +71,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library.""" """Update data via library."""
data = {} data: dict[str, Any] = {}
try: try:
obs = await self.airnow.observations.latLong( obs = await self.airnow.observations.latLong(
self.latitude, self.latitude,

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airnow", "documentation": "https://www.home-assistant.io/integrations/airnow",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pyairnow"], "loggers": ["pyairnow"],
"requirements": ["pyairnow==1.2.1"] "requirements": ["pyairnow==1.3.1"]
} }

View File

@@ -0,0 +1,45 @@
"""The Ubiquiti airOS integration."""
from __future__ import annotations
from airos.airos8 import AirOS
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Set up Ubiquiti airOS from a config entry."""
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(hass, verify_ssl=False)
airos_device = AirOS(
host=entry.data[CONF_HOST],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=session,
)
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -0,0 +1,106 @@
"""AirOS Binary Sensor component for Home Assistant."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describe an AirOS binary sensor."""
value_fn: Callable[[AirOSData], bool]
BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
AirOSBinarySensorEntityDescription(
key="portfw",
translation_key="port_forwarding",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.portfw,
),
AirOSBinarySensorEntityDescription(
key="dhcp_client",
translation_key="dhcp_client",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcpc,
),
AirOSBinarySensorEntityDescription(
key="dhcp_server",
translation_key="dhcp_server",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcpd,
entity_registry_enabled_default=False,
),
AirOSBinarySensorEntityDescription(
key="dhcp6_server",
translation_key="dhcp6_server",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcp6d_stateful,
entity_registry_enabled_default=False,
),
AirOSBinarySensorEntityDescription(
key="pppoe",
translation_key="pppoe",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.pppoe,
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS binary sensors from a config entry."""
coordinator = config_entry.runtime_data
async_add_entities(
AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS
)
class AirOSBinarySensor(AirOSEntity, BinarySensorEntity):
"""Representation of a binary sensor."""
entity_description: AirOSBinarySensorEntityDescription
def __init__(
self,
coordinator: AirOSDataUpdateCoordinator,
description: AirOSBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}"
@property
def is_on(self) -> bool:
"""Return the state of the binary sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -0,0 +1,82 @@
"""Config flow for the Ubiquiti airOS integration."""
from __future__ import annotations
import logging
from typing import Any
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
AirOSKeyDataMissingError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import AirOS
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default="ubnt"): str,
vol.Required(CONF_PASSWORD): str,
}
)
class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS."""
VERSION = 1
async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(self.hass, verify_ssl=False)
airos_device = AirOS(
host=user_input[CONF_HOST],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=session,
)
try:
await airos_device.login()
airos_data = await airos_device.status()
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
):
errors["base"] = "cannot_connect"
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
errors["base"] = "invalid_auth"
except AirOSKeyDataMissingError:
errors["base"] = "key_data_missing"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(airos_data.derived.mac)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=airos_data.host.hostname, data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -0,0 +1,9 @@
"""Constants for the Ubiquiti airOS integration."""
from datetime import timedelta
DOMAIN = "airos"
SCAN_INTERVAL = timedelta(minutes=1)
MANUFACTURER = "Ubiquiti"

View File

@@ -0,0 +1,70 @@
"""DataUpdateCoordinator for AirOS."""
from __future__ import annotations
import logging
from airos.airos8 import AirOS, AirOSData
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
"""Class to manage fetching AirOS data from single endpoint."""
config_entry: AirOSConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS
) -> None:
"""Initialize the coordinator."""
self.airos_device = airos_device
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> AirOSData:
"""Fetch data from AirOS."""
try:
await self.airos_device.login()
return await self.airos_device.status()
except (AirOSConnectionAuthenticationError,) as err:
_LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from err
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
TimeoutError,
) as err:
_LOGGER.error("Error connecting to airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except (AirOSDataMissingError,) as err:
_LOGGER.error("Expected data not returned by airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="error_data_missing",
) from err

View File

@@ -0,0 +1,33 @@
"""Diagnostics support for airOS."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import AirOSConfigEntry
IP_REDACT = ["addr", "ipaddr", "ip6addr", "lastip"] # IP related
HW_REDACT = ["apmac", "hwaddr", "mac"] # MAC address
TO_REDACT_HA = [CONF_HOST, CONF_PASSWORD]
TO_REDACT_AIROS = [
"hostname", # Prevent leaking device naming
"essid", # Network SSID
"lat", # GPS latitude to prevent exposing location data.
"lon", # GPS longitude to prevent exposing location data.
*HW_REDACT,
*IP_REDACT,
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AirOSConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT_HA),
"data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS),
}

View File

@@ -0,0 +1,36 @@
"""Generic AirOS Entity Class."""
from __future__ import annotations
from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import AirOSDataUpdateCoordinator
class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
"""Represent a AirOS Entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None:
"""Initialise the gateway."""
super().__init__(coordinator)
airos_data = self.coordinator.data
configuration_url: str | None = (
f"https://{coordinator.config_entry.data[CONF_HOST]}"
)
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)},
configuration_url=configuration_url,
identifiers={(DOMAIN, str(airos_data.host.device_id))},
manufacturer=MANUFACTURER,
model=airos_data.host.devmodel,
name=airos_data.host.hostname,
sw_version=airos_data.host.fwversion,
)

View File

@@ -0,0 +1,10 @@
{
"domain": "airos",
"name": "Ubiquiti airOS",
"codeowners": ["@CoMPaTech"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["airos==0.3.0"]
}

View File

@@ -0,0 +1,70 @@
rules:
# Bronze
action-setup:
status: exempt
comment: airOS does not have 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: airOS does not have actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: local_polling without 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:
status: exempt
comment: airOS does not have actions
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: no (custom) icons used or envisioned
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,194 @@
"""AirOS Sensor component for Home Assistant."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
UnitOfDataRate,
UnitOfFrequency,
UnitOfLength,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
NETROLE_OPTIONS = [mode.value for mode in NetRole]
WIRELESS_MODE_OPTIONS = [mode.value for mode in DerivedWirelessMode]
WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirOSSensorEntityDescription(SensorEntityDescription):
"""Describe an AirOS sensor."""
value_fn: Callable[[AirOSData], StateType]
SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
AirOSSensorEntityDescription(
key="host_cpuload",
translation_key="host_cpuload",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.host.cpuload,
entity_registry_enabled_default=False,
),
AirOSSensorEntityDescription(
key="host_netrole",
translation_key="host_netrole",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.host.netrole.value,
options=NETROLE_OPTIONS,
),
AirOSSensorEntityDescription(
key="wireless_frequency",
translation_key="wireless_frequency",
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.frequency,
),
AirOSSensorEntityDescription(
key="wireless_essid",
translation_key="wireless_essid",
value_fn=lambda data: data.wireless.essid,
),
AirOSSensorEntityDescription(
key="wireless_antenna_gain",
translation_key="wireless_antenna_gain",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.antenna_gain,
),
AirOSSensorEntityDescription(
key="wireless_throughput_tx",
translation_key="wireless_throughput_tx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.tx,
),
AirOSSensorEntityDescription(
key="wireless_throughput_rx",
translation_key="wireless_throughput_rx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.rx,
),
AirOSSensorEntityDescription(
key="wireless_polling_dl_capacity",
translation_key="wireless_polling_dl_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.dl_capacity,
),
AirOSSensorEntityDescription(
key="wireless_polling_ul_capacity",
translation_key="wireless_polling_ul_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.ul_capacity,
),
AirOSSensorEntityDescription(
key="host_uptime",
translation_key="host_uptime",
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfTime.DAYS,
value_fn=lambda data: data.host.uptime,
entity_registry_enabled_default=False,
),
AirOSSensorEntityDescription(
key="wireless_distance",
translation_key="wireless_distance",
native_unit_of_measurement=UnitOfLength.METERS,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=1,
suggested_unit_of_measurement=UnitOfLength.KILOMETERS,
value_fn=lambda data: data.wireless.distance,
),
AirOSSensorEntityDescription(
key="wireless_mode",
translation_key="wireless_mode",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.derived.mode.value,
options=WIRELESS_MODE_OPTIONS,
entity_registry_enabled_default=False,
),
AirOSSensorEntityDescription(
key="wireless_role",
translation_key="wireless_role",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.derived.role.value,
options=WIRELESS_ROLE_OPTIONS,
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS sensors from a config entry."""
coordinator = config_entry.runtime_data
async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS)
class AirOSSensor(AirOSEntity, SensorEntity):
"""Representation of a Sensor."""
entity_description: AirOSSensorEntityDescription
def __init__(
self,
coordinator: AirOSDataUpdateCoordinator,
description: AirOSSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -0,0 +1,117 @@
{
"config": {
"flow_title": "Ubiquiti airOS device",
"step": {
"user": {
"data": {
"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": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"key_data_missing": "Expected data not returned from the device, check the documentation for supported devices",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"binary_sensor": {
"port_forwarding": {
"name": "Port forwarding"
},
"dhcp_client": {
"name": "DHCP client"
},
"dhcp_server": {
"name": "DHCP server"
},
"dhcp6_server": {
"name": "DHCPv6 server"
},
"pppoe": {
"name": "PPPoE link"
}
},
"sensor": {
"host_cpuload": {
"name": "CPU load"
},
"host_netrole": {
"name": "Network role",
"state": {
"bridge": "Bridge",
"router": "Router"
}
},
"wireless_frequency": {
"name": "Wireless frequency"
},
"wireless_essid": {
"name": "Wireless SSID"
},
"wireless_antenna_gain": {
"name": "Antenna gain"
},
"wireless_throughput_tx": {
"name": "Throughput transmit (actual)"
},
"wireless_throughput_rx": {
"name": "Throughput receive (actual)"
},
"wireless_polling_dl_capacity": {
"name": "Download capacity"
},
"wireless_polling_ul_capacity": {
"name": "Upload capacity"
},
"wireless_remote_hostname": {
"name": "Remote hostname"
},
"host_uptime": {
"name": "Uptime"
},
"wireless_distance": {
"name": "Wireless distance"
},
"wireless_role": {
"name": "Wireless role",
"state": {
"access_point": "Access point",
"station": "Station"
}
},
"wireless_mode": {
"name": "Wireless mode",
"state": {
"point_to_point": "Point-to-point",
"point_to_multipoint": "Point-to-multipoint"
}
}
}
},
"exceptions": {
"invalid_auth": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"cannot_connect": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"key_data_missing": {
"message": "Key data not returned from device"
},
"error_data_missing": {
"message": "Data incomplete or missing"
}
}
}

View File

@@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant
from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE
from .coordinator import AirQCoordinator from .coordinator import AirQCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR]
AirQConfigEntry = ConfigEntry[AirQCoordinator] AirQConfigEntry = ConfigEntry[AirQCoordinator]

View File

@@ -6,6 +6,5 @@ CONF_RETURN_AVERAGE: Final = "return_average"
CONF_CLIP_NEGATIVE: Final = "clip_negatives" CONF_CLIP_NEGATIVE: Final = "clip_negatives"
DOMAIN: Final = "airq" DOMAIN: Final = "airq"
MANUFACTURER: Final = "CorantGmbH" MANUFACTURER: Final = "CorantGmbH"
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³"
UPDATE_INTERVAL: float = 10.0 UPDATE_INTERVAL: float = 10.0

View File

@@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator):
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=UPDATE_INTERVAL), update_interval=timedelta(seconds=UPDATE_INTERVAL),
) )
session = async_get_clientsession(hass) session = async_create_clientsession(hass)
self.airq = AirQ( self.airq = AirQ(
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session
) )
@@ -75,6 +75,7 @@ class AirQCoordinator(DataUpdateCoordinator):
return_average=self.return_average, return_average=self.return_average,
clip_negative_values=self.clip_negative, clip_negative_values=self.clip_negative,
) )
data["brightness"] = await self.airq.get_current_brightness()
if warming_up_sensors := identify_warming_up_sensors(data): if warming_up_sensors := identify_warming_up_sensors(data):
_LOGGER.debug( _LOGGER.debug(
"Following sensors are still warming up: %s", warming_up_sensors "Following sensors are still warming up: %s", warming_up_sensors

View File

@@ -4,9 +4,6 @@
"health_index": { "health_index": {
"default": "mdi:heart-pulse" "default": "mdi:heart-pulse"
}, },
"absolute_humidity": {
"default": "mdi:water"
},
"oxygen": { "oxygen": {
"default": "mdi:leaf" "default": "mdi:leaf"
}, },

View File

@@ -0,0 +1,85 @@
"""Definition of air-Q number platform used to control the LED strips."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from aioairq.core import AirQ
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AirQConfigEntry, AirQCoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class AirQBrightnessDescription(NumberEntityDescription):
"""Describes AirQ number entity responsible for brightness control."""
value: Callable[[dict], float]
set_value: Callable[[AirQ, float], Awaitable[None]]
AIRQ_LED_BRIGHTNESS = AirQBrightnessDescription(
key="airq_led_brightness",
translation_key="airq_led_brightness",
native_min_value=0.0,
native_max_value=100.0,
native_step=1.0,
native_unit_of_measurement=PERCENTAGE,
value=lambda data: data["brightness"],
set_value=lambda device, value: device.set_current_brightness(value),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirQConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities: a single entity for the LEDs."""
coordinator = entry.runtime_data
entities = [AirQLEDBrightness(coordinator, AIRQ_LED_BRIGHTNESS)]
async_add_entities(entities)
class AirQLEDBrightness(CoordinatorEntity[AirQCoordinator], NumberEntity):
"""Representation of the LEDs from a single AirQ."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirQCoordinator,
description: AirQBrightnessDescription,
) -> None:
"""Initialize a single sensor."""
super().__init__(coordinator)
self.entity_description: AirQBrightnessDescription = description
self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
@property
def native_value(self) -> float:
"""Return the brightness of the LEDs in %."""
return self.entity_description.value(self.coordinator.data)
async def async_set_native_value(self, value: float) -> None:
"""Set the brightness of the LEDs to the value in %."""
_LOGGER.debug(
"Changing LED brighntess from %.0f%% to %.0f%%",
self.coordinator.data["brightness"],
value,
)
await self.entity_description.set_value(self.coordinator.airq, value)
await self.coordinator.async_request_refresh()

View File

@@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import ( from homeassistant.const import (
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_BILLION,
@@ -28,10 +29,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AirQConfigEntry, AirQCoordinator from . import AirQConfigEntry, AirQCoordinator
from .const import ( from .const import ACTIVITY_BECQUEREL_PER_CUBIC_METER
ACTIVITY_BECQUEREL_PER_CUBIC_METER,
CONCENTRATION_GRAMS_PER_CUBIC_METER,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -195,7 +193,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
), ),
AirQEntityDescription( AirQEntityDescription(
key="humidity_abs", key="humidity_abs",
translation_key="absolute_humidity", device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY,
native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value=lambda data: data.get("humidity_abs"), value=lambda data: data.get("humidity_abs"),

View File

@@ -35,6 +35,11 @@
} }
}, },
"entity": { "entity": {
"number": {
"airq_led_brightness": {
"name": "LED brightness"
}
},
"sensor": { "sensor": {
"acetaldehyde": { "acetaldehyde": {
"name": "Acetaldehyde" "name": "Acetaldehyde"
@@ -93,9 +98,6 @@
"health_index": { "health_index": {
"name": "Health index" "name": "Health index"
}, },
"absolute_humidity": {
"name": "Absolute humidity"
},
"hydrogen": { "hydrogen": {
"name": "Hydrogen" "name": "Hydrogen"
}, },

View File

@@ -7,21 +7,18 @@ import logging
from airthings import Airthings from airthings import Airthings
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, Platform from homeassistant.const import CONF_ID, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_SECRET from .const import CONF_SECRET
from .coordinator import AirthingsDataUpdateCoordinator from .coordinator import AirthingsConfigEntry, AirthingsDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR]
SCAN_INTERVAL = timedelta(minutes=6) SCAN_INTERVAL = timedelta(minutes=6)
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool:
"""Set up Airthings from a config entry.""" """Set up Airthings from a config entry."""
@@ -31,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) ->
async_get_clientsession(hass), async_get_clientsession(hass),
) )
coordinator = AirthingsDataUpdateCoordinator(hass, airthings) coordinator = AirthingsDataUpdateCoordinator(hass, airthings, entry)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()

View File

@@ -45,6 +45,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
) )
errors = {} errors = {}
await self.async_set_unique_id(user_input[CONF_ID])
self._abort_if_unique_id_configured()
try: try:
await airthings.get_token( await airthings.get_token(
@@ -60,9 +62,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
await self.async_set_unique_id(user_input[CONF_ID])
self._abort_if_unique_id_configured()
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(

View File

@@ -5,6 +5,7 @@ import logging
from airthings import Airthings, AirthingsDevice, AirthingsError from airthings import Airthings, AirthingsDevice, AirthingsError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -13,15 +14,23 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=6) SCAN_INTERVAL = timedelta(minutes=6)
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
class AirthingsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AirthingsDevice]]): class AirthingsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AirthingsDevice]]):
"""Coordinator for Airthings data updates.""" """Coordinator for Airthings data updates."""
def __init__(self, hass: HomeAssistant, airthings: Airthings) -> None: def __init__(
self,
hass: HomeAssistant,
airthings: Airthings,
config_entry: AirthingsConfigEntry,
) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=config_entry,
name=DOMAIN, name=DOMAIN,
update_method=self._update_method, update_method=self._update_method,
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,

View File

@@ -150,7 +150,7 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
entities = [ entities = [
AirthingsHeaterEnergySensor( AirthingsDeviceSensor(
coordinator, coordinator,
airthings_device, airthings_device,
SENSORS[sensor_types], SENSORS[sensor_types],
@@ -162,7 +162,7 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
class AirthingsHeaterEnergySensor( class AirthingsDeviceSensor(
CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity
): ):
"""Representation of a Airthings Sensor device.""" """Representation of a Airthings Sensor device."""

View File

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

View File

@@ -505,8 +505,13 @@ class ClimateCapabilities(AlexaEntity):
): ):
yield AlexaThermostatController(self.hass, self.entity) yield AlexaThermostatController(self.hass, self.entity)
yield AlexaTemperatureSensor(self.hass, self.entity) yield AlexaTemperatureSensor(self.hass, self.entity)
if self.entity.domain == water_heater.DOMAIN and ( if (
supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE self.entity.domain == water_heater.DOMAIN
and (
supported_features
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
)
and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST)
): ):
yield AlexaModeController( yield AlexaModeController(
self.entity, self.entity,
@@ -634,7 +639,9 @@ class FanCapabilities(AlexaEntity):
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}"
) )
force_range_controller = False force_range_controller = False
if supported & fan.FanEntityFeature.PRESET_MODE: if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get(
fan.ATTR_PRESET_MODES
):
yield AlexaModeController( yield AlexaModeController(
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}"
) )
@@ -672,7 +679,11 @@ class RemoteCapabilities(AlexaEntity):
yield AlexaPowerController(self.entity) yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or [] activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
if activities and supported & remote.RemoteEntityFeature.ACTIVITY: if (
activities
and (supported & remote.RemoteEntityFeature.ACTIVITY)
and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
):
yield AlexaModeController( yield AlexaModeController(
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}" self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
) )
@@ -692,7 +703,9 @@ class HumidifierCapabilities(AlexaEntity):
"""Yield the supported interfaces.""" """Yield the supported interfaces."""
yield AlexaPowerController(self.entity) yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & humidifier.HumidifierEntityFeature.MODES: if (
supported & humidifier.HumidifierEntityFeature.MODES
) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES):
yield AlexaModeController( yield AlexaModeController(
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}" self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}"
) )

View File

@@ -2,8 +2,12 @@
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .services import async_setup_services
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
@@ -12,11 +16,20 @@ PLATFORMS = [
Platform.SWITCH, Platform.SWITCH,
] ]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Alexa Devices component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Set up Alexa Devices platform.""" """Set up Alexa Devices platform."""
coordinator = AmazonDevicesCoordinator(hass, entry) session = aiohttp_client.async_create_clientsession(hass)
coordinator = AmazonDevicesCoordinator(hass, entry, session)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
@@ -29,5 +42,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
await entry.runtime_data.api.close()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -7,6 +7,7 @@ from dataclasses import dataclass
from typing import Final from typing import Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import SENSOR_STATE_OFF
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
@@ -28,7 +29,8 @@ PARALLEL_UPDATES = 0
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Alexa Devices binary sensor entity description.""" """Alexa Devices binary sensor entity description."""
is_on_fn: Callable[[AmazonDevice], bool] is_on_fn: Callable[[AmazonDevice, str], bool]
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
BINARY_SENSORS: Final = ( BINARY_SENSORS: Final = (
@@ -36,13 +38,49 @@ BINARY_SENSORS: Final = (
key="online", key="online",
device_class=BinarySensorDeviceClass.CONNECTIVITY, device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda _device: _device.online, is_on_fn=lambda device, _: device.online,
), ),
AmazonBinarySensorEntityDescription( AmazonBinarySensorEntityDescription(
key="bluetooth", key="bluetooth",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
translation_key="bluetooth", translation_key="bluetooth",
is_on_fn=lambda _device: _device.bluetooth_state, is_on_fn=lambda device, _: device.bluetooth_state,
),
AmazonBinarySensorEntityDescription(
key="babyCryDetectionState",
translation_key="baby_cry_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="beepingApplianceDetectionState",
translation_key="beeping_appliance_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="coughDetectionState",
translation_key="cough_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="dogBarkDetectionState",
translation_key="dog_bark_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="humanPresenceDetectionState",
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="waterSoundsDetectionState",
translation_key="water_sounds_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
), ),
) )
@@ -60,6 +98,7 @@ async def async_setup_entry(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in BINARY_SENSORS for sensor_desc in BINARY_SENSORS
for serial_num in coordinator.data for serial_num in coordinator.data
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
) )
@@ -71,4 +110,6 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if the binary sensor is on.""" """Return True if the binary sensor is on."""
return self.entity_description.is_on_fn(self.device) return self.entity_description.is_on_fn(
self.device, self.entity_description.key
)

View File

@@ -2,19 +2,48 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from typing import Any from typing import Any
from aioamazondevices.api import AmazonEchoApi from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect from aioamazondevices.exceptions import (
CannotAuthenticate,
CannotConnect,
CannotRetrieveData,
WrongCountry,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import CountrySelector from homeassistant.helpers.selector import CountrySelector
from .const import CONF_LOGIN_DATA, DOMAIN from .const import CONF_LOGIN_DATA, DOMAIN
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_CODE): cv.string,
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
session = aiohttp_client.async_create_clientsession(hass)
api = AmazonEchoApi(
session,
data[CONF_COUNTRY],
data[CONF_USERNAME],
data[CONF_PASSWORD],
)
return await api.login_mode_interactive(data[CONF_CODE])
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Alexa Devices.""" """Handle a config flow for Alexa Devices."""
@@ -25,17 +54,16 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step.""" """Handle the initial step."""
errors = {} errors = {}
if user_input: if user_input:
client = AmazonEchoApi(
user_input[CONF_COUNTRY],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try: try:
data = await client.login_mode_interactive(user_input[CONF_CODE]) data = await validate_input(self.hass, user_input)
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except CannotAuthenticate: except CannotAuthenticate:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data"
except WrongCountry:
errors["base"] = "wrong_country"
else: else:
await self.async_set_unique_id(data["customer_info"]["user_id"]) await self.async_set_unique_id(data["customer_info"]["user_id"])
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
@@ -44,8 +72,6 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
title=user_input[CONF_USERNAME], title=user_input[CONF_USERNAME],
data=user_input | {CONF_LOGIN_DATA: data}, data=user_input | {CONF_LOGIN_DATA: data},
) )
finally:
await client.close()
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
@@ -61,3 +87,45 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
} }
), ),
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth flow."""
self.context["title_placeholders"] = {CONF_USERNAME: entry_data[CONF_USERNAME]}
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirm."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
entry_data = reauth_entry.data
if user_input is not None:
try:
await validate_input(self.hass, {**reauth_entry.data, **user_input})
except CannotConnect:
errors["base"] = "cannot_connect"
except CannotAuthenticate:
errors["base"] = "invalid_auth"
except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data={
CONF_USERNAME: entry_data[CONF_USERNAME],
CONF_PASSWORD: entry_data[CONF_PASSWORD],
CONF_CODE: user_input[CONF_CODE],
},
)
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_USERNAME: entry_data[CONF_USERNAME]},
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)

View File

@@ -8,14 +8,15 @@ from aioamazondevices.exceptions import (
CannotConnect, CannotConnect,
CannotRetrieveData, CannotRetrieveData,
) )
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
SCAN_INTERVAL = 30 SCAN_INTERVAL = 30
@@ -31,6 +32,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
self, self,
hass: HomeAssistant, hass: HomeAssistant,
entry: AmazonConfigEntry, entry: AmazonConfigEntry,
session: ClientSession,
) -> None: ) -> None:
"""Initialize the scanner.""" """Initialize the scanner."""
super().__init__( super().__init__(
@@ -41,6 +43,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
update_interval=timedelta(seconds=SCAN_INTERVAL), update_interval=timedelta(seconds=SCAN_INTERVAL),
) )
self.api = AmazonEchoApi( self.api = AmazonEchoApi(
session,
entry.data[CONF_COUNTRY], entry.data[CONF_COUNTRY],
entry.data[CONF_USERNAME], entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD], entry.data[CONF_PASSWORD],
@@ -52,7 +55,21 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
try: try:
await self.api.login_mode_stored_data() await self.api.login_mode_stored_data()
return await self.api.get_devices_data() return await self.api.get_devices_data()
except (CannotConnect, CannotRetrieveData) as err: except CannotConnect as err:
raise UpdateFailed(f"Error occurred while updating {self.name}") from err raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except CannotRetrieveData as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
except CannotAuthenticate as err: except CannotAuthenticate as err:
raise ConfigEntryError("Could not authenticate") from err raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err

View File

@@ -2,11 +2,49 @@
"entity": { "entity": {
"binary_sensor": { "binary_sensor": {
"bluetooth": { "bluetooth": {
"default": "mdi:bluetooth", "default": "mdi:bluetooth-off",
"state": { "state": {
"off": "mdi:bluetooth-off" "on": "mdi:bluetooth"
}
},
"baby_cry_detection": {
"default": "mdi:account-voice-off",
"state": {
"on": "mdi:account-voice"
}
},
"beeping_appliance_detection": {
"default": "mdi:bell-off",
"state": {
"on": "mdi:bell-ring"
}
},
"cough_detection": {
"default": "mdi:blur-off",
"state": {
"on": "mdi:blur"
}
},
"dog_bark_detection": {
"default": "mdi:dog-side-off",
"state": {
"on": "mdi:dog-side"
}
},
"water_sounds_detection": {
"default": "mdi:water-pump-off",
"state": {
"on": "mdi:water-pump"
} }
} }
} }
},
"services": {
"send_sound": {
"service": "mdi:cast-audio"
},
"send_text_command": {
"service": "mdi:microphone-message"
}
} }
} }

View File

@@ -7,6 +7,6 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "bronze", "quality_scale": "silver",
"requirements": ["aioamazondevices==3.1.14"] "requirements": ["aioamazondevices==4.0.0"]
} }

View File

@@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@@ -70,6 +71,7 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
entity_description: AmazonNotifyEntityDescription entity_description: AmazonNotifyEntityDescription
@alexa_api_call
async def async_send_message( async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any self, message: str, title: str | None = None, **kwargs: Any
) -> None: ) -> None:

View File

@@ -26,41 +26,39 @@ rules:
unique-config-entry: done unique-config-entry: done
# Silver # Silver
action-exceptions: todo action-exceptions: done
config-entry-unloading: done config-entry-unloading: done
docs-configuration-parameters: todo docs-configuration-parameters: done
docs-installation-parameters: todo docs-installation-parameters: done
entity-unavailable: done entity-unavailable: done
integration-owner: done integration-owner: done
log-when-unavailable: done log-when-unavailable: done
parallel-updates: done parallel-updates: done
reauthentication-flow: todo reauthentication-flow: done
test-coverage: test-coverage: done
status: todo
comment: all tests missing
# Gold # Gold
devices: done devices: done
diagnostics: todo diagnostics: done
discovery-update-info: discovery-update-info:
status: exempt status: exempt
comment: Network information not relevant comment: Network information not relevant
discovery: discovery:
status: exempt status: exempt
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration
docs-data-update: todo docs-data-update: done
docs-examples: todo docs-examples: done
docs-known-limitations: todo docs-known-limitations: done
docs-supported-devices: todo docs-supported-devices: done
docs-supported-functions: todo docs-supported-functions: done
docs-troubleshooting: todo docs-troubleshooting: done
docs-use-cases: todo docs-use-cases: done
dynamic-devices: todo dynamic-devices: todo
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
entity-translations: done entity-translations: done
exception-translations: todo exception-translations: done
icon-translations: done icon-translations: done
reconfiguration-flow: todo reconfiguration-flow: todo
repair-issues: repair-issues:
@@ -72,5 +70,5 @@ rules:
# Platinum # Platinum
async-dependency: done async-dependency: done
inject-websession: todo inject-websession: done
strict-typing: done strict-typing: done

View File

@@ -0,0 +1,121 @@
"""Support for services."""
from aioamazondevices.sounds import SOUNDS_LIST
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN
from .coordinator import AmazonConfigEntry
ATTR_TEXT_COMMAND = "text_command"
ATTR_SOUND = "sound"
ATTR_SOUND_VARIANT = "sound_variant"
SERVICE_TEXT_COMMAND = "send_text_command"
SERVICE_SOUND_NOTIFICATION = "send_sound"
SCHEMA_SOUND_SERVICE = vol.Schema(
{
vol.Required(ATTR_SOUND): cv.string,
vol.Required(ATTR_SOUND_VARIANT): cv.positive_int,
vol.Required(ATTR_DEVICE_ID): cv.string,
},
)
SCHEMA_CUSTOM_COMMAND = vol.Schema(
{
vol.Required(ATTR_TEXT_COMMAND): cv.string,
vol.Required(ATTR_DEVICE_ID): cv.string,
}
)
@callback
def async_get_entry_id_for_service_call(
call: ServiceCall,
) -> tuple[dr.DeviceEntry, AmazonConfigEntry]:
"""Get the entry ID related to a service call (by device ID)."""
device_registry = dr.async_get(call.hass)
device_id = call.data[ATTR_DEVICE_ID]
if (device_entry := device_registry.async_get(device_id)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_device_id",
translation_placeholders={"device_id": device_id},
)
for entry_id in device_entry.config_entries:
if (entry := call.hass.config_entries.async_get_entry(entry_id)) is None:
continue
if entry.domain == DOMAIN:
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
translation_placeholders={"entry": entry.title},
)
return (device_entry, entry)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_found",
translation_placeholders={"device_id": device_id},
)
async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
"""Execute action on the device."""
device, config_entry = async_get_entry_id_for_service_call(call)
assert device.serial_number
value: str = call.data[attribute]
coordinator = config_entry.runtime_data
if attribute == ATTR_SOUND:
variant: int = call.data[ATTR_SOUND_VARIANT]
pad = "_" if variant > 10 else "_0"
file = f"{value}{pad}{variant!s}"
if value not in SOUNDS_LIST or variant > SOUNDS_LIST[value]:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_sound_value",
translation_placeholders={"sound": value, "variant": str(variant)},
)
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], file
)
elif attribute == ATTR_TEXT_COMMAND:
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
async def async_send_sound_notification(call: ServiceCall) -> None:
"""Send a sound notification to a AmazonDevice."""
await _async_execute_action(call, ATTR_SOUND)
async def async_send_text_command(call: ServiceCall) -> None:
"""Send a custom command to a AmazonDevice."""
await _async_execute_action(call, ATTR_TEXT_COMMAND)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Amazon Devices integration."""
for service_name, method, schema in (
(
SERVICE_SOUND_NOTIFICATION,
async_send_sound_notification,
SCHEMA_SOUND_SERVICE,
),
(
SERVICE_TEXT_COMMAND,
async_send_text_command,
SCHEMA_CUSTOM_COMMAND,
),
):
hass.services.async_register(DOMAIN, service_name, method, schema=schema)

View File

@@ -0,0 +1,504 @@
send_text_command:
fields:
device_id:
required: true
selector:
device:
integration: alexa_devices
text_command:
required: true
example: "Play B.B.C. on TuneIn"
selector:
text:
send_sound:
fields:
device_id:
required: true
selector:
device:
integration: alexa_devices
sound_variant:
required: true
example: 1
default: 1
selector:
number:
min: 1
max: 50
sound:
required: true
example: amzn_sfx_doorbell_chime
default: amzn_sfx_doorbell_chime
selector:
select:
options:
- air_horn
- air_horns
- airboat
- airport
- aliens
- amzn_sfx_airplane_takeoff_whoosh
- amzn_sfx_army_march_clank_7x
- amzn_sfx_army_march_large_8x
- amzn_sfx_army_march_small_8x
- amzn_sfx_baby_big_cry
- amzn_sfx_baby_cry
- amzn_sfx_baby_fuss
- amzn_sfx_battle_group_clanks
- amzn_sfx_battle_man_grunts
- amzn_sfx_battle_men_grunts
- amzn_sfx_battle_men_horses
- amzn_sfx_battle_noisy_clanks
- amzn_sfx_battle_yells_men
- amzn_sfx_battle_yells_men_run
- amzn_sfx_bear_groan_roar
- amzn_sfx_bear_roar_grumble
- amzn_sfx_bear_roar_small
- amzn_sfx_beep_1x
- amzn_sfx_bell_med_chime
- amzn_sfx_bell_short_chime
- amzn_sfx_bell_timer
- amzn_sfx_bicycle_bell_ring
- amzn_sfx_bird_chickadee_chirp_1x
- amzn_sfx_bird_chickadee_chirps
- amzn_sfx_bird_forest
- amzn_sfx_bird_forest_short
- amzn_sfx_bird_robin_chirp_1x
- amzn_sfx_boing_long_1x
- amzn_sfx_boing_med_1x
- amzn_sfx_boing_short_1x
- amzn_sfx_bus_drive_past
- amzn_sfx_buzz_electronic
- amzn_sfx_buzzer_loud_alarm
- amzn_sfx_buzzer_small
- amzn_sfx_car_accelerate
- amzn_sfx_car_accelerate_noisy
- amzn_sfx_car_click_seatbelt
- amzn_sfx_car_close_door_1x
- amzn_sfx_car_drive_past
- amzn_sfx_car_honk_1x
- amzn_sfx_car_honk_2x
- amzn_sfx_car_honk_3x
- amzn_sfx_car_honk_long_1x
- amzn_sfx_car_into_driveway
- amzn_sfx_car_into_driveway_fast
- amzn_sfx_car_slam_door_1x
- amzn_sfx_car_undo_seatbelt
- amzn_sfx_cat_angry_meow_1x
- amzn_sfx_cat_angry_screech_1x
- amzn_sfx_cat_long_meow_1x
- amzn_sfx_cat_meow_1x
- amzn_sfx_cat_purr
- amzn_sfx_cat_purr_meow
- amzn_sfx_chicken_cluck
- amzn_sfx_church_bell_1x
- amzn_sfx_church_bells_ringing
- amzn_sfx_clear_throat_ahem
- amzn_sfx_clock_ticking
- amzn_sfx_clock_ticking_long
- amzn_sfx_copy_machine
- amzn_sfx_cough
- amzn_sfx_crow_caw_1x
- amzn_sfx_crowd_applause
- amzn_sfx_crowd_bar
- amzn_sfx_crowd_bar_rowdy
- amzn_sfx_crowd_boo
- amzn_sfx_crowd_cheer_med
- amzn_sfx_crowd_excited_cheer
- amzn_sfx_dog_med_bark_1x
- amzn_sfx_dog_med_bark_2x
- amzn_sfx_dog_med_bark_growl
- amzn_sfx_dog_med_growl_1x
- amzn_sfx_dog_med_woof_1x
- amzn_sfx_dog_small_bark_2x
- amzn_sfx_door_open
- amzn_sfx_door_shut
- amzn_sfx_doorbell
- amzn_sfx_doorbell_buzz
- amzn_sfx_doorbell_chime
- amzn_sfx_drinking_slurp
- amzn_sfx_drum_and_cymbal
- amzn_sfx_drum_comedy
- amzn_sfx_earthquake_rumble
- amzn_sfx_electric_guitar
- amzn_sfx_electronic_beep
- amzn_sfx_electronic_major_chord
- amzn_sfx_elephant
- amzn_sfx_elevator_bell_1x
- amzn_sfx_elevator_open_bell
- amzn_sfx_fairy_melodic_chimes
- amzn_sfx_fairy_sparkle_chimes
- amzn_sfx_faucet_drip
- amzn_sfx_faucet_running
- amzn_sfx_fireplace_crackle
- amzn_sfx_fireworks
- amzn_sfx_fireworks_firecrackers
- amzn_sfx_fireworks_launch
- amzn_sfx_fireworks_whistles
- amzn_sfx_food_frying
- amzn_sfx_footsteps
- amzn_sfx_footsteps_muffled
- amzn_sfx_ghost_spooky
- amzn_sfx_glass_on_table
- amzn_sfx_glasses_clink
- amzn_sfx_horse_gallop_4x
- amzn_sfx_horse_huff_whinny
- amzn_sfx_horse_neigh
- amzn_sfx_horse_neigh_low
- amzn_sfx_horse_whinny
- amzn_sfx_human_walking
- amzn_sfx_jar_on_table_1x
- amzn_sfx_kitchen_ambience
- amzn_sfx_large_crowd_cheer
- amzn_sfx_large_fire_crackling
- amzn_sfx_laughter
- amzn_sfx_laughter_giggle
- amzn_sfx_lightning_strike
- amzn_sfx_lion_roar
- amzn_sfx_magic_blast_1x
- amzn_sfx_monkey_calls_3x
- amzn_sfx_monkey_chimp
- amzn_sfx_monkeys_chatter
- amzn_sfx_motorcycle_accelerate
- amzn_sfx_motorcycle_engine_idle
- amzn_sfx_motorcycle_engine_rev
- amzn_sfx_musical_drone_intro
- amzn_sfx_oars_splashing_rowboat
- amzn_sfx_object_on_table_2x
- amzn_sfx_ocean_wave_1x
- amzn_sfx_ocean_wave_on_rocks_1x
- amzn_sfx_ocean_wave_surf
- amzn_sfx_people_walking
- amzn_sfx_person_running
- amzn_sfx_piano_note_1x
- amzn_sfx_punch
- amzn_sfx_rain
- amzn_sfx_rain_on_roof
- amzn_sfx_rain_thunder
- amzn_sfx_rat_squeak_2x
- amzn_sfx_rat_squeaks
- amzn_sfx_raven_caw_1x
- amzn_sfx_raven_caw_2x
- amzn_sfx_restaurant_ambience
- amzn_sfx_rooster_crow
- amzn_sfx_scifi_air_escaping
- amzn_sfx_scifi_alarm
- amzn_sfx_scifi_alien_voice
- amzn_sfx_scifi_boots_walking
- amzn_sfx_scifi_close_large_explosion
- amzn_sfx_scifi_door_open
- amzn_sfx_scifi_engines_on
- amzn_sfx_scifi_engines_on_large
- amzn_sfx_scifi_engines_on_short_burst
- amzn_sfx_scifi_explosion
- amzn_sfx_scifi_explosion_2x
- amzn_sfx_scifi_incoming_explosion
- amzn_sfx_scifi_laser_gun_battle
- amzn_sfx_scifi_laser_gun_fires
- amzn_sfx_scifi_laser_gun_fires_large
- amzn_sfx_scifi_long_explosion_1x
- amzn_sfx_scifi_missile
- amzn_sfx_scifi_motor_short_1x
- amzn_sfx_scifi_open_airlock
- amzn_sfx_scifi_radar_high_ping
- amzn_sfx_scifi_radar_low
- amzn_sfx_scifi_radar_medium
- amzn_sfx_scifi_run_away
- amzn_sfx_scifi_sheilds_up
- amzn_sfx_scifi_short_low_explosion
- amzn_sfx_scifi_small_whoosh_flyby
- amzn_sfx_scifi_small_zoom_flyby
- amzn_sfx_scifi_sonar_ping_3x
- amzn_sfx_scifi_sonar_ping_4x
- amzn_sfx_scifi_spaceship_flyby
- amzn_sfx_scifi_timer_beep
- amzn_sfx_scifi_zap_backwards
- amzn_sfx_scifi_zap_electric
- amzn_sfx_sheep_baa
- amzn_sfx_sheep_bleat
- amzn_sfx_silverware_clank
- amzn_sfx_sirens
- amzn_sfx_sleigh_bells
- amzn_sfx_small_stream
- amzn_sfx_sneeze
- amzn_sfx_stream
- amzn_sfx_strong_wind_desert
- amzn_sfx_strong_wind_whistling
- amzn_sfx_subway_leaving
- amzn_sfx_subway_passing
- amzn_sfx_subway_stopping
- amzn_sfx_swoosh_cartoon_fast
- amzn_sfx_swoosh_fast_1x
- amzn_sfx_swoosh_fast_6x
- amzn_sfx_test_tone
- amzn_sfx_thunder_rumble
- amzn_sfx_toilet_flush
- amzn_sfx_trumpet_bugle
- amzn_sfx_turkey_gobbling
- amzn_sfx_typing_medium
- amzn_sfx_typing_short
- amzn_sfx_typing_typewriter
- amzn_sfx_vacuum_off
- amzn_sfx_vacuum_on
- amzn_sfx_walking_in_mud
- amzn_sfx_walking_in_snow
- amzn_sfx_walking_on_grass
- amzn_sfx_water_dripping
- amzn_sfx_water_droplets
- amzn_sfx_wind_strong_gusting
- amzn_sfx_wind_whistling_desert
- amzn_sfx_wings_flap_4x
- amzn_sfx_wings_flap_fast
- amzn_sfx_wolf_howl
- amzn_sfx_wolf_young_howl
- amzn_sfx_wooden_door
- amzn_sfx_wooden_door_creaks_long
- amzn_sfx_wooden_door_creaks_multiple
- amzn_sfx_wooden_door_creaks_open
- amzn_ui_sfx_gameshow_bridge
- amzn_ui_sfx_gameshow_countdown_loop_32s_full
- amzn_ui_sfx_gameshow_countdown_loop_64s_full
- amzn_ui_sfx_gameshow_countdown_loop_64s_minimal
- amzn_ui_sfx_gameshow_intro
- amzn_ui_sfx_gameshow_negative_response
- amzn_ui_sfx_gameshow_neutral_response
- amzn_ui_sfx_gameshow_outro
- amzn_ui_sfx_gameshow_player1
- amzn_ui_sfx_gameshow_player2
- amzn_ui_sfx_gameshow_player3
- amzn_ui_sfx_gameshow_player4
- amzn_ui_sfx_gameshow_positive_response
- amzn_ui_sfx_gameshow_tally_negative
- amzn_ui_sfx_gameshow_tally_positive
- amzn_ui_sfx_gameshow_waiting_loop_30s
- anchor
- answering_machines
- arcs_sparks
- arrows_bows
- baby
- back_up_beeps
- bars_restaurants
- baseball
- basketball
- battles
- beeps_tones
- bell
- bikes
- billiards
- board_games
- body
- boing
- books
- bow_wash
- box
- break_shatter_smash
- breaks
- brooms_mops
- bullets
- buses
- buzz
- buzz_hums
- buzzers
- buzzers_pistols
- cables_metal
- camera
- cannons
- car_alarm
- car_alarms
- car_cell_phones
- carnivals_fairs
- cars
- casino
- casinos
- cellar
- chimes
- chimes_bells
- chorus
- christmas
- church_bells
- clock
- cloth
- concrete
- construction
- construction_factory
- crashes
- crowds
- debris
- dining_kitchens
- dinosaurs
- dripping
- drops
- electric
- electrical
- elevator
- evolution_monsters
- explosions
- factory
- falls
- fax_scanner_copier
- feedback_mics
- fight
- fire
- fire_extinguisher
- fireballs
- fireworks
- fishing_pole
- flags
- football
- footsteps
- futuristic
- futuristic_ship
- gameshow
- gear
- ghosts_demons
- giant_monster
- glass
- glasses_clink
- golf
- gorilla
- grenade_lanucher
- griffen
- gyms_locker_rooms
- handgun_loading
- handgun_shot
- handle
- hands
- heartbeats_ekg
- helicopter
- high_tech
- hit_punch_slap
- hits
- horns
- horror
- hot_tub_filling_up
- human
- human_vocals
- hygene # codespell:ignore
- ice_skating
- ignitions
- infantry
- intro
- jet
- juggling
- key_lock
- kids
- knocks
- lab_equip
- lacrosse
- lamps_lanterns
- leather
- liquid_suction
- locker_doors
- machine_gun
- magic_spells
- medium_large_explosions
- metal
- modern_rings
- money_coins
- motorcycles
- movement
- moves
- nature
- oar_boat
- pagers
- paintball
- paper
- parachute
- pay_phones
- phone_beeps
- pigmy_bats
- pills
- pour_water
- power_up_down
- printers
- prison
- public_space
- racquetball
- radios_static
- rain
- rc_airplane
- rc_car
- refrigerators_freezers
- regular
- respirator
- rifle
- roller_coaster
- rollerskates_rollerblades
- room_tones
- ropes_climbing
- rotary_rings
- rowboat_canoe
- rubber
- running
- sails
- sand_gravel
- screen_doors
- screens
- seats_stools
- servos
- shoes_boots
- shotgun
- shower
- sink_faucet
- sink_filling_water
- sink_run_and_off
- sink_water_splatter
- sirens
- skateboards
- ski
- skids_tires
- sled
- slides
- small_explosions
- snow
- snowmobile
- soldiers
- splash_water
- splashes_sprays
- sports_whistles
- squeaks
- squeaky
- stairs
- steam
- submarine_diesel
- swing_doors
- switches_levers
- swords
- tape
- tape_machine
- televisions_shows
- tennis_pingpong
- textile
- throw
- thunder
- ticks
- timer
- toilet_flush
- tone
- tones_noises
- toys
- tractors
- traffic
- train
- trucks_vans
- turnstiles
- typing
- umbrella
- underwater
- vampires
- various
- video_tunes
- volcano_earthquake
- watches
- water
- water_running
- werewolves
- winches_gears
- wind
- wood
- wood_boat
- woosh
- zap
- zippers
translation_key: sound

View File

@@ -1,21 +1,21 @@
{ {
"common": { "common": {
"data_country": "Country code",
"data_code": "One-time password (OTP code)", "data_code": "One-time password (OTP code)",
"data_description_country": "The country of your Amazon account.", "data_description_country": "The country where your Amazon account is registered.",
"data_description_username": "The email address of your Amazon account.", "data_description_username": "The email address of your Amazon account.",
"data_description_password": "The password of your Amazon account.", "data_description_password": "The password of your Amazon account.",
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported." "data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported.",
"device_id_description": "The ID of the device to send the command to."
}, },
"config": { "config": {
"flow_title": "{username}", "flow_title": "{username}",
"step": { "step": {
"user": { "user": {
"data": { "data": {
"country": "[%key:component::alexa_devices::common::data_country%]", "country": "[%key:common::config_flow::data::country%]",
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]" "code": "[%key:component::alexa_devices::common::data_code%]"
}, },
"data_description": { "data_description": {
"country": "[%key:component::alexa_devices::common::data_description_country%]", "country": "[%key:component::alexa_devices::common::data_description_country%]",
@@ -23,17 +23,30 @@
"password": "[%key:component::alexa_devices::common::data_description_password%]", "password": "[%key:component::alexa_devices::common::data_description_password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]" "code": "[%key:component::alexa_devices::common::data_description_code%]"
} }
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"code": "[%key:component::alexa_devices::common::data_code%]"
},
"data_description": {
"password": "[%key:component::alexa_devices::common::data_description_password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
}
} }
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"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%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"cannot_retrieve_data": "Unable to retrieve data from Amazon. Please try again later.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
} }
}, },
@@ -41,6 +54,21 @@
"binary_sensor": { "binary_sensor": {
"bluetooth": { "bluetooth": {
"name": "Bluetooth" "name": "Bluetooth"
},
"baby_cry_detection": {
"name": "Baby crying"
},
"beeping_appliance_detection": {
"name": "Beeping appliance"
},
"cough_detection": {
"name": "Coughing"
},
"dog_bark_detection": {
"name": "Dog barking"
},
"water_sounds_detection": {
"name": "Water sounds"
} }
}, },
"notify": { "notify": {
@@ -56,5 +84,533 @@
"name": "Do not disturb" "name": "Do not disturb"
} }
} }
},
"services": {
"send_sound": {
"name": "Send sound",
"description": "Sends a sound to a device",
"fields": {
"device_id": {
"name": "Device",
"description": "[%key:component::alexa_devices::common::device_id_description%]"
},
"sound": {
"name": "Alexa Skill sound file",
"description": "The sound file to play."
},
"sound_variant": {
"name": "Sound variant",
"description": "The variant of the sound to play."
}
}
},
"send_text_command": {
"name": "Send text command",
"description": "Sends a text command to a device",
"fields": {
"text_command": {
"name": "Alexa text command",
"description": "The text command to send."
},
"device_id": {
"name": "Device",
"description": "[%key:component::alexa_devices::common::device_id_description%]"
}
}
}
},
"selector": {
"sound": {
"options": {
"air_horn": "Air Horn",
"air_horns": "Air Horns",
"airboat": "Airboat",
"airport": "Airport",
"aliens": "Aliens",
"amzn_sfx_airplane_takeoff_whoosh": "Airplane Takeoff Whoosh",
"amzn_sfx_army_march_clank_7x": "Army March Clank 7x",
"amzn_sfx_army_march_large_8x": "Army March Large 8x",
"amzn_sfx_army_march_small_8x": "Army March Small 8x",
"amzn_sfx_baby_big_cry": "Baby Big Cry",
"amzn_sfx_baby_cry": "Baby Cry",
"amzn_sfx_baby_fuss": "Baby Fuss",
"amzn_sfx_battle_group_clanks": "Battle Group Clanks",
"amzn_sfx_battle_man_grunts": "Battle Man Grunts",
"amzn_sfx_battle_men_grunts": "Battle Men Grunts",
"amzn_sfx_battle_men_horses": "Battle Men Horses",
"amzn_sfx_battle_noisy_clanks": "Battle Noisy Clanks",
"amzn_sfx_battle_yells_men": "Battle Yells Men",
"amzn_sfx_battle_yells_men_run": "Battle Yells Men Run",
"amzn_sfx_bear_groan_roar": "Bear Groan Roar",
"amzn_sfx_bear_roar_grumble": "Bear Roar Grumble",
"amzn_sfx_bear_roar_small": "Bear Roar Small",
"amzn_sfx_beep_1x": "Beep 1x",
"amzn_sfx_bell_med_chime": "Bell Med Chime",
"amzn_sfx_bell_short_chime": "Bell Short Chime",
"amzn_sfx_bell_timer": "Bell Timer",
"amzn_sfx_bicycle_bell_ring": "Bicycle Bell Ring",
"amzn_sfx_bird_chickadee_chirp_1x": "Bird Chickadee Chirp 1x",
"amzn_sfx_bird_chickadee_chirps": "Bird Chickadee Chirps",
"amzn_sfx_bird_forest": "Bird Forest",
"amzn_sfx_bird_forest_short": "Bird Forest Short",
"amzn_sfx_bird_robin_chirp_1x": "Bird Robin Chirp 1x",
"amzn_sfx_boing_long_1x": "Boing Long 1x",
"amzn_sfx_boing_med_1x": "Boing Med 1x",
"amzn_sfx_boing_short_1x": "Boing Short 1x",
"amzn_sfx_bus_drive_past": "Bus Drive Past",
"amzn_sfx_buzz_electronic": "Buzz Electronic",
"amzn_sfx_buzzer_loud_alarm": "Buzzer Loud Alarm",
"amzn_sfx_buzzer_small": "Buzzer Small",
"amzn_sfx_car_accelerate": "Car Accelerate",
"amzn_sfx_car_accelerate_noisy": "Car Accelerate Noisy",
"amzn_sfx_car_click_seatbelt": "Car Click Seatbelt",
"amzn_sfx_car_close_door_1x": "Car Close Door 1x",
"amzn_sfx_car_drive_past": "Car Drive Past",
"amzn_sfx_car_honk_1x": "Car Honk 1x",
"amzn_sfx_car_honk_2x": "Car Honk 2x",
"amzn_sfx_car_honk_3x": "Car Honk 3x",
"amzn_sfx_car_honk_long_1x": "Car Honk Long 1x",
"amzn_sfx_car_into_driveway": "Car Into Driveway",
"amzn_sfx_car_into_driveway_fast": "Car Into Driveway Fast",
"amzn_sfx_car_slam_door_1x": "Car Slam Door 1x",
"amzn_sfx_car_undo_seatbelt": "Car Undo Seatbelt",
"amzn_sfx_cat_angry_meow_1x": "Cat Angry Meow 1x",
"amzn_sfx_cat_angry_screech_1x": "Cat Angry Screech 1x",
"amzn_sfx_cat_long_meow_1x": "Cat Long Meow 1x",
"amzn_sfx_cat_meow_1x": "Cat Meow 1x",
"amzn_sfx_cat_purr": "Cat Purr",
"amzn_sfx_cat_purr_meow": "Cat Purr Meow",
"amzn_sfx_chicken_cluck": "Chicken Cluck",
"amzn_sfx_church_bell_1x": "Church Bell 1x",
"amzn_sfx_church_bells_ringing": "Church Bells Ringing",
"amzn_sfx_clear_throat_ahem": "Clear Throat Ahem",
"amzn_sfx_clock_ticking": "Clock Ticking",
"amzn_sfx_clock_ticking_long": "Clock Ticking Long",
"amzn_sfx_copy_machine": "Copy Machine",
"amzn_sfx_cough": "Cough",
"amzn_sfx_crow_caw_1x": "Crow Caw 1x",
"amzn_sfx_crowd_applause": "Crowd Applause",
"amzn_sfx_crowd_bar": "Crowd Bar",
"amzn_sfx_crowd_bar_rowdy": "Crowd Bar Rowdy",
"amzn_sfx_crowd_boo": "Crowd Boo",
"amzn_sfx_crowd_cheer_med": "Crowd Cheer Med",
"amzn_sfx_crowd_excited_cheer": "Crowd Excited Cheer",
"amzn_sfx_dog_med_bark_1x": "Dog Med Bark 1x",
"amzn_sfx_dog_med_bark_2x": "Dog Med Bark 2x",
"amzn_sfx_dog_med_bark_growl": "Dog Med Bark Growl",
"amzn_sfx_dog_med_growl_1x": "Dog Med Growl 1x",
"amzn_sfx_dog_med_woof_1x": "Dog Med Woof 1x",
"amzn_sfx_dog_small_bark_2x": "Dog Small Bark 2x",
"amzn_sfx_door_open": "Door Open",
"amzn_sfx_door_shut": "Door Shut",
"amzn_sfx_doorbell": "Doorbell",
"amzn_sfx_doorbell_buzz": "Doorbell Buzz",
"amzn_sfx_doorbell_chime": "Doorbell Chime",
"amzn_sfx_drinking_slurp": "Drinking Slurp",
"amzn_sfx_drum_and_cymbal": "Drum And Cymbal",
"amzn_sfx_drum_comedy": "Drum Comedy",
"amzn_sfx_earthquake_rumble": "Earthquake Rumble",
"amzn_sfx_electric_guitar": "Electric Guitar",
"amzn_sfx_electronic_beep": "Electronic Beep",
"amzn_sfx_electronic_major_chord": "Electronic Major Chord",
"amzn_sfx_elephant": "Elephant",
"amzn_sfx_elevator_bell_1x": "Elevator Bell 1x",
"amzn_sfx_elevator_open_bell": "Elevator Open Bell",
"amzn_sfx_fairy_melodic_chimes": "Fairy Melodic Chimes",
"amzn_sfx_fairy_sparkle_chimes": "Fairy Sparkle Chimes",
"amzn_sfx_faucet_drip": "Faucet Drip",
"amzn_sfx_faucet_running": "Faucet Running",
"amzn_sfx_fireplace_crackle": "Fireplace Crackle",
"amzn_sfx_fireworks": "Fireworks",
"amzn_sfx_fireworks_firecrackers": "Fireworks Firecrackers",
"amzn_sfx_fireworks_launch": "Fireworks Launch",
"amzn_sfx_fireworks_whistles": "Fireworks Whistles",
"amzn_sfx_food_frying": "Food Frying",
"amzn_sfx_footsteps": "Footsteps",
"amzn_sfx_footsteps_muffled": "Footsteps Muffled",
"amzn_sfx_ghost_spooky": "Ghost Spooky",
"amzn_sfx_glass_on_table": "Glass On Table",
"amzn_sfx_glasses_clink": "Glasses Clink",
"amzn_sfx_horse_gallop_4x": "Horse Gallop 4x",
"amzn_sfx_horse_huff_whinny": "Horse Huff Whinny",
"amzn_sfx_horse_neigh": "Horse Neigh",
"amzn_sfx_horse_neigh_low": "Horse Neigh Low",
"amzn_sfx_horse_whinny": "Horse Whinny",
"amzn_sfx_human_walking": "Human Walking",
"amzn_sfx_jar_on_table_1x": "Jar On Table 1x",
"amzn_sfx_kitchen_ambience": "Kitchen Ambience",
"amzn_sfx_large_crowd_cheer": "Large Crowd Cheer",
"amzn_sfx_large_fire_crackling": "Large Fire Crackling",
"amzn_sfx_laughter": "Laughter",
"amzn_sfx_laughter_giggle": "Laughter Giggle",
"amzn_sfx_lightning_strike": "Lightning Strike",
"amzn_sfx_lion_roar": "Lion Roar",
"amzn_sfx_magic_blast_1x": "Magic Blast 1x",
"amzn_sfx_monkey_calls_3x": "Monkey Calls 3x",
"amzn_sfx_monkey_chimp": "Monkey Chimp",
"amzn_sfx_monkeys_chatter": "Monkeys Chatter",
"amzn_sfx_motorcycle_accelerate": "Motorcycle Accelerate",
"amzn_sfx_motorcycle_engine_idle": "Motorcycle Engine Idle",
"amzn_sfx_motorcycle_engine_rev": "Motorcycle Engine Rev",
"amzn_sfx_musical_drone_intro": "Musical Drone Intro",
"amzn_sfx_oars_splashing_rowboat": "Oars Splashing Rowboat",
"amzn_sfx_object_on_table_2x": "Object On Table 2x",
"amzn_sfx_ocean_wave_1x": "Ocean Wave 1x",
"amzn_sfx_ocean_wave_on_rocks_1x": "Ocean Wave On Rocks 1x",
"amzn_sfx_ocean_wave_surf": "Ocean Wave Surf",
"amzn_sfx_people_walking": "People Walking",
"amzn_sfx_person_running": "Person Running",
"amzn_sfx_piano_note_1x": "Piano Note 1x",
"amzn_sfx_punch": "Punch",
"amzn_sfx_rain": "Rain",
"amzn_sfx_rain_on_roof": "Rain On Roof",
"amzn_sfx_rain_thunder": "Rain Thunder",
"amzn_sfx_rat_squeak_2x": "Rat Squeak 2x",
"amzn_sfx_rat_squeaks": "Rat Squeaks",
"amzn_sfx_raven_caw_1x": "Raven Caw 1x",
"amzn_sfx_raven_caw_2x": "Raven Caw 2x",
"amzn_sfx_restaurant_ambience": "Restaurant Ambience",
"amzn_sfx_rooster_crow": "Rooster Crow",
"amzn_sfx_scifi_air_escaping": "Scifi Air Escaping",
"amzn_sfx_scifi_alarm": "Scifi Alarm",
"amzn_sfx_scifi_alien_voice": "Scifi Alien Voice",
"amzn_sfx_scifi_boots_walking": "Scifi Boots Walking",
"amzn_sfx_scifi_close_large_explosion": "Scifi Close Large Explosion",
"amzn_sfx_scifi_door_open": "Scifi Door Open",
"amzn_sfx_scifi_engines_on": "Scifi Engines On",
"amzn_sfx_scifi_engines_on_large": "Scifi Engines On Large",
"amzn_sfx_scifi_engines_on_short_burst": "Scifi Engines On Short Burst",
"amzn_sfx_scifi_explosion": "Scifi Explosion",
"amzn_sfx_scifi_explosion_2x": "Scifi Explosion 2x",
"amzn_sfx_scifi_incoming_explosion": "Scifi Incoming Explosion",
"amzn_sfx_scifi_laser_gun_battle": "Scifi Laser Gun Battle",
"amzn_sfx_scifi_laser_gun_fires": "Scifi Laser Gun Fires",
"amzn_sfx_scifi_laser_gun_fires_large": "Scifi Laser Gun Fires Large",
"amzn_sfx_scifi_long_explosion_1x": "Scifi Long Explosion 1x",
"amzn_sfx_scifi_missile": "Scifi Missile",
"amzn_sfx_scifi_motor_short_1x": "Scifi Motor Short 1x",
"amzn_sfx_scifi_open_airlock": "Scifi Open Airlock",
"amzn_sfx_scifi_radar_high_ping": "Scifi Radar High Ping",
"amzn_sfx_scifi_radar_low": "Scifi Radar Low",
"amzn_sfx_scifi_radar_medium": "Scifi Radar Medium",
"amzn_sfx_scifi_run_away": "Scifi Run Away",
"amzn_sfx_scifi_sheilds_up": "Scifi Sheilds Up",
"amzn_sfx_scifi_short_low_explosion": "Scifi Short Low Explosion",
"amzn_sfx_scifi_small_whoosh_flyby": "Scifi Small Whoosh Flyby",
"amzn_sfx_scifi_small_zoom_flyby": "Scifi Small Zoom Flyby",
"amzn_sfx_scifi_sonar_ping_3x": "Scifi Sonar Ping 3x",
"amzn_sfx_scifi_sonar_ping_4x": "Scifi Sonar Ping 4x",
"amzn_sfx_scifi_spaceship_flyby": "Scifi Spaceship Flyby",
"amzn_sfx_scifi_timer_beep": "Scifi Timer Beep",
"amzn_sfx_scifi_zap_backwards": "Scifi Zap Backwards",
"amzn_sfx_scifi_zap_electric": "Scifi Zap Electric",
"amzn_sfx_sheep_baa": "Sheep Baa",
"amzn_sfx_sheep_bleat": "Sheep Bleat",
"amzn_sfx_silverware_clank": "Silverware Clank",
"amzn_sfx_sirens": "Sirens",
"amzn_sfx_sleigh_bells": "Sleigh Bells",
"amzn_sfx_small_stream": "Small Stream",
"amzn_sfx_sneeze": "Sneeze",
"amzn_sfx_stream": "Stream",
"amzn_sfx_strong_wind_desert": "Strong Wind Desert",
"amzn_sfx_strong_wind_whistling": "Strong Wind Whistling",
"amzn_sfx_subway_leaving": "Subway Leaving",
"amzn_sfx_subway_passing": "Subway Passing",
"amzn_sfx_subway_stopping": "Subway Stopping",
"amzn_sfx_swoosh_cartoon_fast": "Swoosh Cartoon Fast",
"amzn_sfx_swoosh_fast_1x": "Swoosh Fast 1x",
"amzn_sfx_swoosh_fast_6x": "Swoosh Fast 6x",
"amzn_sfx_test_tone": "Test Tone",
"amzn_sfx_thunder_rumble": "Thunder Rumble",
"amzn_sfx_toilet_flush": "Toilet Flush",
"amzn_sfx_trumpet_bugle": "Trumpet Bugle",
"amzn_sfx_turkey_gobbling": "Turkey Gobbling",
"amzn_sfx_typing_medium": "Typing Medium",
"amzn_sfx_typing_short": "Typing Short",
"amzn_sfx_typing_typewriter": "Typing Typewriter",
"amzn_sfx_vacuum_off": "Vacuum Off",
"amzn_sfx_vacuum_on": "Vacuum On",
"amzn_sfx_walking_in_mud": "Walking In Mud",
"amzn_sfx_walking_in_snow": "Walking In Snow",
"amzn_sfx_walking_on_grass": "Walking On Grass",
"amzn_sfx_water_dripping": "Water Dripping",
"amzn_sfx_water_droplets": "Water Droplets",
"amzn_sfx_wind_strong_gusting": "Wind Strong Gusting",
"amzn_sfx_wind_whistling_desert": "Wind Whistling Desert",
"amzn_sfx_wings_flap_4x": "Wings Flap 4x",
"amzn_sfx_wings_flap_fast": "Wings Flap Fast",
"amzn_sfx_wolf_howl": "Wolf Howl",
"amzn_sfx_wolf_young_howl": "Wolf Young Howl",
"amzn_sfx_wooden_door": "Wooden Door",
"amzn_sfx_wooden_door_creaks_long": "Wooden Door Creaks Long",
"amzn_sfx_wooden_door_creaks_multiple": "Wooden Door Creaks Multiple",
"amzn_sfx_wooden_door_creaks_open": "Wooden Door Creaks Open",
"amzn_ui_sfx_gameshow_bridge": "Gameshow Bridge",
"amzn_ui_sfx_gameshow_countdown_loop_32s_full": "Gameshow Countdown Loop 32s Full",
"amzn_ui_sfx_gameshow_countdown_loop_64s_full": "Gameshow Countdown Loop 64s Full",
"amzn_ui_sfx_gameshow_countdown_loop_64s_minimal": "Gameshow Countdown Loop 64s Minimal",
"amzn_ui_sfx_gameshow_intro": "Gameshow Intro",
"amzn_ui_sfx_gameshow_negative_response": "Gameshow Negative Response",
"amzn_ui_sfx_gameshow_neutral_response": "Gameshow Neutral Response",
"amzn_ui_sfx_gameshow_outro": "Gameshow Outro",
"amzn_ui_sfx_gameshow_player1": "Gameshow Player1",
"amzn_ui_sfx_gameshow_player2": "Gameshow Player2",
"amzn_ui_sfx_gameshow_player3": "Gameshow Player3",
"amzn_ui_sfx_gameshow_player4": "Gameshow Player4",
"amzn_ui_sfx_gameshow_positive_response": "Gameshow Positive Response",
"amzn_ui_sfx_gameshow_tally_negative": "Gameshow Tally Negative",
"amzn_ui_sfx_gameshow_tally_positive": "Gameshow Tally Positive",
"amzn_ui_sfx_gameshow_waiting_loop_30s": "Gameshow Waiting Loop 30s",
"anchor": "Anchor",
"answering_machines": "Answering Machines",
"arcs_sparks": "Arcs Sparks",
"arrows_bows": "Arrows Bows",
"baby": "Baby",
"back_up_beeps": "Back Up Beeps",
"bars_restaurants": "Bars Restaurants",
"baseball": "Baseball",
"basketball": "Basketball",
"battles": "Battles",
"beeps_tones": "Beeps Tones",
"bell": "Bell",
"bikes": "Bikes",
"billiards": "Billiards",
"board_games": "Board Games",
"body": "Body",
"boing": "Boing",
"books": "Books",
"bow_wash": "Bow Wash",
"box": "Box",
"break_shatter_smash": "Break Shatter Smash",
"breaks": "Breaks",
"brooms_mops": "Brooms Mops",
"bullets": "Bullets",
"buses": "Buses",
"buzz": "Buzz",
"buzz_hums": "Buzz Hums",
"buzzers": "Buzzers",
"buzzers_pistols": "Buzzers Pistols",
"cables_metal": "Cables Metal",
"camera": "Camera",
"cannons": "Cannons",
"car_alarm": "Car Alarm",
"car_alarms": "Car Alarms",
"car_cell_phones": "Car Cell Phones",
"carnivals_fairs": "Carnivals Fairs",
"cars": "Cars",
"casino": "Casino",
"casinos": "Casinos",
"cellar": "Cellar",
"chimes": "Chimes",
"chimes_bells": "Chimes Bells",
"chorus": "Chorus",
"christmas": "Christmas",
"church_bells": "Church Bells",
"clock": "Clock",
"cloth": "Cloth",
"concrete": "Concrete",
"construction": "Construction",
"construction_factory": "Construction Factory",
"crashes": "Crashes",
"crowds": "Crowds",
"debris": "Debris",
"dining_kitchens": "Dining Kitchens",
"dinosaurs": "Dinosaurs",
"dripping": "Dripping",
"drops": "Drops",
"electric": "Electric",
"electrical": "Electrical",
"elevator": "Elevator",
"evolution_monsters": "Evolution Monsters",
"explosions": "Explosions",
"factory": "Factory",
"falls": "Falls",
"fax_scanner_copier": "Fax Scanner Copier",
"feedback_mics": "Feedback Mics",
"fight": "Fight",
"fire": "Fire",
"fire_extinguisher": "Fire Extinguisher",
"fireballs": "Fireballs",
"fireworks": "Fireworks",
"fishing_pole": "Fishing Pole",
"flags": "Flags",
"football": "Football",
"footsteps": "Footsteps",
"futuristic": "Futuristic",
"futuristic_ship": "Futuristic Ship",
"gameshow": "Gameshow",
"gear": "Gear",
"ghosts_demons": "Ghosts Demons",
"giant_monster": "Giant Monster",
"glass": "Glass",
"glasses_clink": "Glasses Clink",
"golf": "Golf",
"gorilla": "Gorilla",
"grenade_lanucher": "Grenade Lanucher",
"griffen": "Griffen",
"gyms_locker_rooms": "Gyms Locker Rooms",
"handgun_loading": "Handgun Loading",
"handgun_shot": "Handgun Shot",
"handle": "Handle",
"hands": "Hands",
"heartbeats_ekg": "Heartbeats EKG",
"helicopter": "Helicopter",
"high_tech": "High Tech",
"hit_punch_slap": "Hit Punch Slap",
"hits": "Hits",
"horns": "Horns",
"horror": "Horror",
"hot_tub_filling_up": "Hot Tub Filling Up",
"human": "Human",
"human_vocals": "Human Vocals",
"hygene": "Hygene",
"ice_skating": "Ice Skating",
"ignitions": "Ignitions",
"infantry": "Infantry",
"intro": "Intro",
"jet": "Jet",
"juggling": "Juggling",
"key_lock": "Key Lock",
"kids": "Kids",
"knocks": "Knocks",
"lab_equip": "Lab Equip",
"lacrosse": "Lacrosse",
"lamps_lanterns": "Lamps Lanterns",
"leather": "Leather",
"liquid_suction": "Liquid Suction",
"locker_doors": "Locker Doors",
"machine_gun": "Machine Gun",
"magic_spells": "Magic Spells",
"medium_large_explosions": "Medium Large Explosions",
"metal": "Metal",
"modern_rings": "Modern Rings",
"money_coins": "Money Coins",
"motorcycles": "Motorcycles",
"movement": "Movement",
"moves": "Moves",
"nature": "Nature",
"oar_boat": "Oar Boat",
"pagers": "Pagers",
"paintball": "Paintball",
"paper": "Paper",
"parachute": "Parachute",
"pay_phones": "Pay Phones",
"phone_beeps": "Phone Beeps",
"pigmy_bats": "Pigmy Bats",
"pills": "Pills",
"pour_water": "Pour Water",
"power_up_down": "Power Up Down",
"printers": "Printers",
"prison": "Prison",
"public_space": "Public Space",
"racquetball": "Racquetball",
"radios_static": "Radios Static",
"rain": "Rain",
"rc_airplane": "RC Airplane",
"rc_car": "RC Car",
"refrigerators_freezers": "Refrigerators Freezers",
"regular": "Regular",
"respirator": "Respirator",
"rifle": "Rifle",
"roller_coaster": "Roller Coaster",
"rollerskates_rollerblades": "RollerSkates RollerBlades",
"room_tones": "Room Tones",
"ropes_climbing": "Ropes Climbing",
"rotary_rings": "Rotary Rings",
"rowboat_canoe": "Rowboat Canoe",
"rubber": "Rubber",
"running": "Running",
"sails": "Sails",
"sand_gravel": "Sand Gravel",
"screen_doors": "Screen Doors",
"screens": "Screens",
"seats_stools": "Seats Stools",
"servos": "Servos",
"shoes_boots": "Shoes Boots",
"shotgun": "Shotgun",
"shower": "Shower",
"sink_faucet": "Sink Faucet",
"sink_filling_water": "Sink Filling Water",
"sink_run_and_off": "Sink Run And Off",
"sink_water_splatter": "Sink Water Splatter",
"sirens": "Sirens",
"skateboards": "Skateboards",
"ski": "Ski",
"skids_tires": "Skids Tires",
"sled": "Sled",
"slides": "Slides",
"small_explosions": "Small Explosions",
"snow": "Snow",
"snowmobile": "Snowmobile",
"soldiers": "Soldiers",
"splash_water": "Splash Water",
"splashes_sprays": "Splashes Sprays",
"sports_whistles": "Sports Whistles",
"squeaks": "Squeaks",
"squeaky": "Squeaky",
"stairs": "Stairs",
"steam": "Steam",
"submarine_diesel": "Submarine Diesel",
"swing_doors": "Swing Doors",
"switches_levers": "Switches Levers",
"swords": "Swords",
"tape": "Tape",
"tape_machine": "Tape Machine",
"televisions_shows": "Televisions Shows",
"tennis_pingpong": "Tennis PingPong",
"textile": "Textile",
"throw": "Throw",
"thunder": "Thunder",
"ticks": "Ticks",
"timer": "Timer",
"toilet_flush": "Toilet Flush",
"tone": "Tone",
"tones_noises": "Tones Noises",
"toys": "Toys",
"tractors": "Tractors",
"traffic": "Traffic",
"train": "Train",
"trucks_vans": "Trucks Vans",
"turnstiles": "Turnstiles",
"typing": "Typing",
"umbrella": "Umbrella",
"underwater": "Underwater",
"vampires": "Vampires",
"various": "Various",
"video_tunes": "Video Tunes",
"volcano_earthquake": "Volcano Earthquake",
"watches": "Watches",
"water": "Water",
"water_running": "Water Running",
"werewolves": "Werewolves",
"winches_gears": "Winches Gears",
"wind": "Wind",
"wood": "Wood",
"wood_boat": "Wood Boat",
"woosh": "Woosh",
"zap": "Zap",
"zippers": "Zippers"
}
}
},
"exceptions": {
"cannot_connect_with_error": {
"message": "Error connecting: {error}"
},
"cannot_retrieve_data_with_error": {
"message": "Error retrieving data: {error}"
},
"device_serial_number_missing": {
"message": "Device serial number missing: {device_id}"
},
"invalid_device_id": {
"message": "Invalid device ID specified: {device_id}"
},
"invalid_sound_value": {
"message": "Invalid sound {sound} with variant {variant} specified"
},
"entry_not_loaded": {
"message": "Entry not loaded: {entry}"
}
} }
} }

View File

@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@@ -60,6 +61,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
entity_description: AmazonSwitchEntityDescription entity_description: AmazonSwitchEntityDescription
@alexa_api_call
async def _switch_set_state(self, state: bool) -> None: async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state.""" """Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method) method = getattr(self.coordinator.api, self.entity_description.method)

View File

@@ -0,0 +1,40 @@
"""Utils for Alexa Devices."""
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .entity import AmazonEntity
def alexa_api_call[_T: AmazonEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
"""Catch Alexa API call exceptions."""
@wraps(func)
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap all command methods."""
try:
await func(self, *args, **kwargs)
except CannotConnect as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except CannotRetrieveData as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
return cmd_wrapper

View File

@@ -2,11 +2,22 @@
import amberelectric import amberelectric
from homeassistant.components.sensor import ConfigType
from homeassistant.const import CONF_API_TOKEN from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from .const import CONF_SITE_ID, PLATFORMS from .const import CONF_SITE_ID, DOMAIN, PLATFORMS
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
from .services import setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Amber component."""
setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool:

View File

@@ -16,7 +16,7 @@ from homeassistant.helpers.selector import (
SelectSelectorMode, SelectSelectorMode,
) )
from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN, REQUEST_TIMEOUT
API_URL = "https://app.amber.com.au/developers" API_URL = "https://app.amber.com.au/developers"
@@ -64,7 +64,9 @@ class AmberElectricConfigFlow(ConfigFlow, domain=DOMAIN):
api = amberelectric.AmberApi(api_client) api = amberelectric.AmberApi(api_client)
try: try:
sites: list[Site] = filter_sites(api.get_sites()) sites: list[Site] = filter_sites(
api.get_sites(_request_timeout=REQUEST_TIMEOUT)
)
except amberelectric.ApiException as api_exception: except amberelectric.ApiException as api_exception:
if api_exception.status == 403: if api_exception.status == 403:
self._errors[CONF_API_TOKEN] = "invalid_api_token" self._errors[CONF_API_TOKEN] = "invalid_api_token"

View File

@@ -1,14 +1,25 @@
"""Amber Electric Constants.""" """Amber Electric Constants."""
import logging import logging
from typing import Final
from homeassistant.const import Platform from homeassistant.const import Platform
DOMAIN = "amberelectric" DOMAIN: Final = "amberelectric"
CONF_SITE_NAME = "site_name" CONF_SITE_NAME = "site_name"
CONF_SITE_ID = "site_id" CONF_SITE_ID = "site_id"
ATTR_CHANNEL_TYPE = "channel_type"
ATTRIBUTION = "Data provided by Amber Electric" ATTRIBUTION = "Data provided by Amber Electric"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
SERVICE_GET_FORECASTS = "get_forecasts"
GENERAL_CHANNEL = "general"
CONTROLLED_LOAD_CHANNEL = "controlled_load"
FEED_IN_CHANNEL = "feed_in"
REQUEST_TIMEOUT = 15

View File

@@ -10,14 +10,14 @@ from amberelectric.models.actual_interval import ActualInterval
from amberelectric.models.channel import ChannelType from amberelectric.models.channel import ChannelType
from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.current_interval import CurrentInterval
from amberelectric.models.forecast_interval import ForecastInterval from amberelectric.models.forecast_interval import ForecastInterval
from amberelectric.models.price_descriptor import PriceDescriptor
from amberelectric.rest import ApiException from amberelectric.rest import ApiException
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER from .const import LOGGER, REQUEST_TIMEOUT
from .helpers import normalize_descriptor
type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator] type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator]
@@ -49,27 +49,6 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) ->
return interval.channel_type == ChannelType.FEEDIN return interval.channel_type == ChannelType.FEEDIN
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
if descriptor is None:
return None
if descriptor.value == "spike":
return "spike"
if descriptor.value == "high":
return "high"
if descriptor.value == "neutral":
return "neutral"
if descriptor.value == "low":
return "low"
if descriptor.value == "veryLow":
return "very_low"
if descriptor.value == "extremelyLow":
return "extremely_low"
if descriptor.value == "negative":
return "negative"
return None
class AmberUpdateCoordinator(DataUpdateCoordinator): class AmberUpdateCoordinator(DataUpdateCoordinator):
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
@@ -103,7 +82,11 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
"grid": {}, "grid": {},
} }
try: try:
data = self._api.get_current_prices(self.site_id, next=48) data = self._api.get_current_prices(
self.site_id,
next=288,
_request_timeout=REQUEST_TIMEOUT,
)
intervals = [interval.actual_instance for interval in data] intervals = [interval.actual_instance for interval in data]
except ApiException as api_exception: except ApiException as api_exception:
raise UpdateFailed("Missing price data, skipping update") from api_exception raise UpdateFailed("Missing price data, skipping update") from api_exception

View File

@@ -0,0 +1,25 @@
"""Formatting helpers used to convert things."""
from amberelectric.models.price_descriptor import PriceDescriptor
DESCRIPTOR_MAP: dict[str, str] = {
PriceDescriptor.SPIKE: "spike",
PriceDescriptor.HIGH: "high",
PriceDescriptor.NEUTRAL: "neutral",
PriceDescriptor.LOW: "low",
PriceDescriptor.VERYLOW: "very_low",
PriceDescriptor.EXTREMELYLOW: "extremely_low",
PriceDescriptor.NEGATIVE: "negative",
}
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
if descriptor in DESCRIPTOR_MAP:
return DESCRIPTOR_MAP[descriptor]
return None
def format_cents_to_dollars(cents: float) -> float:
"""Return a formatted conversion from cents to dollars."""
return round(cents / 100, 2)

View File

@@ -22,5 +22,10 @@
} }
} }
} }
},
"services": {
"get_forecasts": {
"service": "mdi:transmission-tower"
}
} }
} }

View File

@@ -23,16 +23,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION from .const import ATTRIBUTION
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
from .helpers import format_cents_to_dollars, normalize_descriptor
UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}" UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}"
def format_cents_to_dollars(cents: float) -> float:
"""Return a formatted conversion from cents to dollars."""
return round(cents / 100, 2)
def friendly_channel_type(channel_type: str) -> str: def friendly_channel_type(channel_type: str) -> str:
"""Return a human readable version of the channel type.""" """Return a human readable version of the channel type."""
if channel_type == "controlled_load": if channel_type == "controlled_load":

View File

@@ -0,0 +1,121 @@
"""Amber Electric Service class."""
from amberelectric.models.channel import ChannelType
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.util.json import JsonValueType
from .const import (
ATTR_CHANNEL_TYPE,
CONTROLLED_LOAD_CHANNEL,
DOMAIN,
FEED_IN_CHANNEL,
GENERAL_CHANNEL,
SERVICE_GET_FORECASTS,
)
from .coordinator import AmberConfigEntry
from .helpers import format_cents_to_dollars, normalize_descriptor
GET_FORECASTS_SCHEMA = vol.Schema(
{
ATTR_CONFIG_ENTRY_ID: ConfigEntrySelector({"integration": DOMAIN}),
ATTR_CHANNEL_TYPE: vol.In(
[GENERAL_CHANNEL, CONTROLLED_LOAD_CHANNEL, FEED_IN_CHANNEL]
),
}
)
def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry:
"""Get the Amber config entry."""
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": config_entry_id},
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": entry.title},
)
return entry
def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]:
"""Return an array of forecasts."""
results: list[JsonValueType] = []
if channel_type not in data["forecasts"]:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="channel_not_found",
translation_placeholders={"channel_type": channel_type},
)
intervals = data["forecasts"][channel_type]
for interval in intervals:
datum = {}
datum["duration"] = interval.duration
datum["date"] = interval.var_date.isoformat()
datum["nem_date"] = interval.nem_time.isoformat()
datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
if interval.channel_type == ChannelType.FEEDIN:
datum["per_kwh"] = datum["per_kwh"] * -1
datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
datum["start_time"] = interval.start_time.isoformat()
datum["end_time"] = interval.end_time.isoformat()
datum["renewables"] = round(interval.renewables)
datum["spike_status"] = interval.spike_status.value
datum["descriptor"] = normalize_descriptor(interval.descriptor)
if interval.range is not None:
datum["range_min"] = format_cents_to_dollars(interval.range.min)
datum["range_max"] = format_cents_to_dollars(interval.range.max)
if interval.advanced_price is not None:
multiplier = -1 if interval.channel_type == ChannelType.FEEDIN else 1
datum["advanced_price_low"] = multiplier * format_cents_to_dollars(
interval.advanced_price.low
)
datum["advanced_price_predicted"] = multiplier * format_cents_to_dollars(
interval.advanced_price.predicted
)
datum["advanced_price_high"] = multiplier * format_cents_to_dollars(
interval.advanced_price.high
)
results.append(datum)
return results
def setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Amber integration."""
async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse:
channel_type = call.data[ATTR_CHANNEL_TYPE]
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
coordinator = entry.runtime_data
forecasts = get_forecasts(channel_type, coordinator.data)
return {"forecasts": forecasts}
hass.services.async_register(
DOMAIN,
SERVICE_GET_FORECASTS,
handle_get_forecasts,
GET_FORECASTS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)

View File

@@ -0,0 +1,16 @@
get_forecasts:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: amberelectric
channel_type:
required: true
selector:
select:
options:
- general
- controlled_load
- feed_in
translation_key: channel_type

View File

@@ -1,25 +1,61 @@
{ {
"config": { "config": {
"error": {
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]",
"no_site": "No site provided",
"unknown_error": "[%key:common::config_flow::error::unknown%]"
},
"step": { "step": {
"site": {
"data": {
"site_id": "Site NMI",
"site_name": "Site name"
},
"description": "Select the NMI of the site you would like to add"
},
"user": { "user": {
"data": { "data": {
"api_token": "[%key:common::config_flow::data::api_token%]", "api_token": "[%key:common::config_flow::data::api_token%]",
"site_id": "Site ID" "site_id": "Site ID"
}, },
"description": "Go to {api_url} to generate an API key" "description": "Go to {api_url} to generate an API key"
},
"site": {
"data": {
"site_id": "Site NMI",
"site_name": "Site Name"
},
"description": "Select the NMI of the site you would like to add"
} }
}
},
"services": {
"get_forecasts": {
"name": "Get price forecasts",
"description": "Retrieves price forecasts from Amber Electric for a site.",
"fields": {
"config_entry_id": {
"description": "The config entry of the site to get forecasts for.",
"name": "Config entry"
},
"channel_type": {
"name": "Channel type",
"description": "The channel to get forecasts for."
}
}
}
},
"exceptions": {
"integration_not_found": {
"message": "Config entry \"{target}\" not found in registry."
}, },
"error": { "not_loaded": {
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", "message": "{target} is not loaded."
"no_site": "No site provided", },
"unknown_error": "[%key:common::config_flow::error::unknown%]" "channel_not_found": {
"message": "There is no {channel_type} channel at this site."
}
},
"selector": {
"channel_type": {
"options": {
"general": "General",
"controlled_load": "Controlled load",
"feed_in": "Feed-in"
}
} }
} }
} }

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from aioambient.util import get_public_device_id from aioambient.util import get_public_device_id
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
@@ -37,6 +37,7 @@ class AmbientWeatherEntity(Entity):
identifiers={(DOMAIN, mac_address)}, identifiers={(DOMAIN, mac_address)},
manufacturer="Ambient Weather", manufacturer="Ambient Weather",
name=station_name.capitalize(), name=station_name.capitalize(),
connections={(CONNECTION_NETWORK_MAC, mac_address)},
) )
self._attr_unique_id = f"{mac_address}_{description.key}" self._attr_unique_id = f"{mac_address}_{description.key}"

View File

@@ -7,5 +7,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["amcrest"], "loggers": ["amcrest"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["amcrest==1.9.8"] "requirements": ["amcrest==1.9.9"]
} }

View File

@@ -14,6 +14,7 @@ from homeassistant.util.hass_dict import HassKey
from .analytics import Analytics from .analytics import Analytics
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
from .http import AnalyticsDevicesView
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@@ -55,6 +56,8 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
websocket_api.async_register_command(hass, websocket_analytics) websocket_api.async_register_command(hass, websocket_analytics)
websocket_api.async_register_command(hass, websocket_analytics_preferences) websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
hass.data[DATA_COMPONENT] = analytics hass.data[DATA_COMPONENT] = analytics
return True return True

View File

@@ -27,7 +27,7 @@ from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er 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 homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
@@ -77,6 +77,11 @@ from .const import (
) )
def gen_uuid() -> str:
"""Generate a new UUID."""
return uuid.uuid4().hex
@dataclass @dataclass
class AnalyticsData: class AnalyticsData:
"""Analytics data.""" """Analytics data."""
@@ -184,7 +189,7 @@ class Analytics:
return return
if self._data.uuid is None: if self._data.uuid is None:
self._data.uuid = uuid.uuid4().hex self._data.uuid = gen_uuid()
await self._store.async_save(dataclass_asdict(self._data)) await self._store.async_save(dataclass_asdict(self._data))
if self.supervisor: if self.supervisor:
@@ -381,3 +386,68 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
).values(): ).values():
domains.update(platforms) domains.update(platforms)
return domains return domains
async def async_devices_payload(hass: HomeAssistant) -> dict:
"""Return the devices payload."""
devices: list[dict[str, Any]] = []
dev_reg = dr.async_get(hass)
# Devices that need via device info set
new_indexes: dict[str, int] = {}
via_devices: dict[str, str] = {}
seen_integrations = set()
for device in dev_reg.devices.values():
if not device.primary_config_entry:
continue
config_entry = hass.config_entries.async_get_entry(device.primary_config_entry)
if config_entry is None:
continue
seen_integrations.add(config_entry.domain)
new_indexes[device.id] = len(devices)
devices.append(
{
"integration": config_entry.domain,
"manufacturer": device.manufacturer,
"model_id": device.model_id,
"model": device.model,
"sw_version": device.sw_version,
"hw_version": device.hw_version,
"has_configuration_url": device.configuration_url is not None,
"via_device": None,
"entry_type": device.entry_type.value if device.entry_type else None,
}
)
if device.via_device_id:
via_devices[device.id] = device.via_device_id
for from_device, via_device in via_devices.items():
if via_device not in new_indexes:
continue
devices[new_indexes[from_device]]["via_device"] = new_indexes[via_device]
integrations = {
domain: integration
for domain, integration in (
await async_get_integrations(hass, seen_integrations)
).items()
if isinstance(integration, Integration)
}
for device_info in devices:
if integration := integrations.get(device_info["integration"]):
device_info["is_custom_integration"] = not integration.is_built_in
# Include version for custom integrations
if not integration.is_built_in and integration.version:
device_info["custom_integration_version"] = str(integration.version)
return {
"version": "home-assistant:1",
"devices": devices,
}

View File

@@ -0,0 +1,27 @@
"""HTTP endpoints for analytics integration."""
from aiohttp import web
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.core import HomeAssistant
from .analytics import async_devices_payload
class AnalyticsDevicesView(HomeAssistantView):
"""View to handle analytics devices payload download requests."""
url = "/api/analytics/devices"
name = "api:analytics:devices"
@require_admin
async def get(self, request: web.Request) -> web.Response:
"""Return analytics devices payload as JSON."""
hass: HomeAssistant = request.app[KEY_HASS]
payload = await async_devices_payload(hass)
return self.json(
payload,
headers={
"Content-Disposition": "attachment; filename=analytics_devices.json"
},
)

View File

@@ -3,7 +3,7 @@
"name": "Analytics", "name": "Analytics",
"after_dependencies": ["energy", "hassio", "recorder"], "after_dependencies": ["energy", "hassio", "recorder"],
"codeowners": ["@home-assistant/core", "@ludeeus"], "codeowners": ["@home-assistant/core", "@ludeeus"],
"dependencies": ["api", "websocket_api"], "dependencies": ["api", "websocket_api", "http"],
"documentation": "https://www.home-assistant.io/integrations/analytics", "documentation": "https://www.home-assistant.io/integrations/analytics",
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",

View File

@@ -55,7 +55,6 @@ async def async_setup_entry(
entry.runtime_data = AnalyticsInsightsData(coordinator=coordinator, names=names) entry.runtime_data = AnalyticsInsightsData(coordinator=coordinator, names=names)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True return True
@@ -65,10 +64,3 @@ async def async_unload_entry(
) -> bool: ) -> 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)
async def update_listener(
hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -11,7 +11,11 @@ from python_homeassistant_analytics import (
from python_homeassistant_analytics.models import Environment, IntegrationType from python_homeassistant_analytics.models import Environment, IntegrationType
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
@@ -129,7 +133,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
) )
class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
"""Handle Homeassistant Analytics options.""" """Handle Homeassistant Analytics options."""
async def async_step_init( async def async_step_init(

View File

@@ -30,10 +30,9 @@ class AndroidIPCamDataUpdateCoordinator(DataUpdateCoordinator[None]):
cam: PyDroidIPCam, cam: PyDroidIPCam,
) -> None: ) -> None:
"""Initialize the Android IP Webcam.""" """Initialize the Android IP Webcam."""
self.hass = hass
self.cam = cam self.cam = cam
super().__init__( super().__init__(
self.hass, hass,
_LOGGER, _LOGGER,
config_entry=config_entry, config_entry=config_entry,
name=f"{DOMAIN} {config_entry.data[CONF_HOST]}", name=f"{DOMAIN} {config_entry.data[CONF_HOST]}",

View File

@@ -56,7 +56,7 @@ SERVICE_UPLOAD = "upload"
ANDROIDTV_STATES = { ANDROIDTV_STATES = {
"off": MediaPlayerState.OFF, "off": MediaPlayerState.OFF,
"idle": MediaPlayerState.IDLE, "idle": MediaPlayerState.IDLE,
"standby": MediaPlayerState.STANDBY, "standby": MediaPlayerState.IDLE,
"playing": MediaPlayerState.PLAYING, "playing": MediaPlayerState.PLAYING,
"paused": MediaPlayerState.PAUSED, "paused": MediaPlayerState.PAUSED,
} }

View File

@@ -5,26 +5,18 @@ from __future__ import annotations
from asyncio import timeout from asyncio import timeout
import logging import logging
from androidtvremote2 import ( from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth
AndroidTVRemote,
CannotConnect,
ConnectionClosed,
InvalidAuth,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .helpers import create_api, get_enable_ime from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE] PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE]
AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote]
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
@@ -76,21 +68,14 @@ async def async_setup_entry(
entry.async_on_unload( entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
) )
entry.async_on_unload(entry.add_update_listener(async_update_options))
entry.async_on_unload(api.disconnect) entry.async_on_unload(api.disconnect)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
_LOGGER.debug("async_unload_entry: %s", entry.data) _LOGGER.debug("async_unload_entry: %s", entry.data)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
_LOGGER.debug(
"async_update_options: data: %s options: %s", entry.data, entry.options
)
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -16,10 +16,10 @@ import voluptuous as vol
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_REAUTH, SOURCE_REAUTH,
ConfigEntry, SOURCE_RECONFIGURE,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlowWithReload,
) )
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import callback from homeassistant.core import callback
@@ -33,7 +33,7 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN
from .helpers import create_api, get_enable_ime from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -41,12 +41,6 @@ APPS_NEW_ID = "NewApp"
CONF_APP_DELETE = "app_delete" CONF_APP_DELETE = "app_delete"
CONF_APP_ID = "app_id" CONF_APP_ID = "app_id"
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
}
)
STEP_PAIR_DATA_SCHEMA = vol.Schema( STEP_PAIR_DATA_SCHEMA = vol.Schema(
{ {
vol.Required("pin"): str, vol.Required("pin"): str,
@@ -67,7 +61,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
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 initial step.""" """Handle the initial and reconfigure step."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
self.host = user_input[CONF_HOST] self.host = user_input[CONF_HOST]
@@ -76,15 +70,32 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
await api.async_generate_cert_if_missing() await api.async_generate_cert_if_missing()
self.name, self.mac = await api.async_get_name_and_mac() self.name, self.mac = await api.async_get_name_and_mac()
await self.async_set_unique_id(format_mac(self.mac)) await self.async_set_unique_id(format_mac(self.mac))
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data={
CONF_HOST: self.host,
CONF_NAME: self.name,
CONF_MAC: self.mac,
},
)
self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
return await self._async_start_pair() return await self._async_start_pair()
except (CannotConnect, ConnectionClosed): except (CannotConnect, ConnectionClosed):
# Likely invalid IP address or device is network unreachable. Stay # Likely invalid IP address or device is network unreachable. Stay
# in the user step allowing the user to enter a different host. # in the user step allowing the user to enter a different host.
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
else:
user_input = {}
default_host = user_input.get(CONF_HOST, vol.UNDEFINED)
if self.source == SOURCE_RECONFIGURE:
default_host = self._get_reconfigure_entry().data[CONF_HOST]
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user",
data_schema=STEP_USER_DATA_SCHEMA, data_schema=vol.Schema(
{vol.Required(CONF_HOST, default=default_host): str}
),
errors=errors, errors=errors,
) )
@@ -105,10 +116,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
pin = user_input["pin"] pin = user_input["pin"]
await self.api.async_finish_pairing(pin) await self.api.async_finish_pairing(pin)
if self.source == SOURCE_REAUTH: if self.source == SOURCE_REAUTH:
await self.hass.config_entries.async_reload( return self.async_update_reload_and_abort(
self._get_reauth_entry().entry_id self._get_reauth_entry(), reload_even_if_entry_is_unchanged=True
) )
return self.async_abort(reason="reauth_successful")
return self.async_create_entry( return self.async_create_entry(
title=self.name, title=self.name,
data={ data={
@@ -217,19 +228,25 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self.async_step_user(user_input)
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
config_entry: ConfigEntry, config_entry: AndroidTVRemoteConfigEntry,
) -> AndroidTVRemoteOptionsFlowHandler: ) -> AndroidTVRemoteOptionsFlowHandler:
"""Create the options flow.""" """Create the options flow."""
return AndroidTVRemoteOptionsFlowHandler(config_entry) return AndroidTVRemoteOptionsFlowHandler(config_entry)
class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithReload):
"""Android TV Remote options flow.""" """Android TV Remote options flow."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: AndroidTVRemoteConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {})) self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {}))
self._conf_app_id: str | None = None self._conf_app_id: str | None = None

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