mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 19:27:45 +00:00
Merge branch 'dev' into deprecate-ContextVar-for-using-config_entry-in-coordinator
This commit is contained in:
commit
8c875067cc
@ -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
|
||||||
|
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -1,15 +1,14 @@
|
|||||||
name: Report an issue with Home Assistant Core
|
name: Report an issue with Home Assistant Core
|
||||||
description: Report an issue with Home Assistant Core.
|
description: Report an issue with Home Assistant Core.
|
||||||
type: Bug
|
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
This issue form is for reporting bugs only!
|
This issue form is for reporting bugs only!
|
||||||
|
|
||||||
If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr].
|
If you have a feature or enhancement request, please [request them here instead][fr].
|
||||||
|
|
||||||
[fr]: https://community.home-assistant.io/c/feature-requests
|
[fr]: https://github.com/orgs/home-assistant/discussions
|
||||||
- type: textarea
|
- type: textarea
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -10,8 +10,8 @@ contact_links:
|
|||||||
url: https://www.home-assistant.io/help
|
url: https://www.home-assistant.io/help
|
||||||
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
||||||
- name: Feature Request
|
- name: Feature Request
|
||||||
url: https://community.home-assistant.io/c/feature-requests
|
url: https://github.com/orgs/home-assistant/discussions
|
||||||
about: Please use our Community Forum for making feature requests.
|
about: Please use this link to request new features or enhancements to existing features.
|
||||||
- name: I'm unsure where to go
|
- name: I'm unsure where to go
|
||||||
url: https://www.home-assistant.io/join-chat
|
url: https://www.home-assistant.io/join-chat
|
||||||
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
||||||
|
53
.github/ISSUE_TEMPLATE/task.yml
vendored
Normal file
53
.github/ISSUE_TEMPLATE/task.yml
vendored
Normal 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
|
1219
.github/copilot-instructions.md
vendored
1219
.github/copilot-instructions.md
vendored
File diff suppressed because it is too large
Load Diff
24
.github/workflows/builder.yml
vendored
24
.github/workflows/builder.yml
vendored
@ -32,7 +32,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
@ -94,7 +94,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Download nightly wheels of frontend
|
- name: Download nightly wheels of frontend
|
||||||
if: needs.init.outputs.channel == 'dev'
|
if: needs.init.outputs.channel == 'dev'
|
||||||
uses: dawidd6/action-download-artifact@v9
|
uses: dawidd6/action-download-artifact@v11
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
repo: home-assistant/frontend
|
repo: home-assistant/frontend
|
||||||
@ -105,10 +105,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Download nightly wheels of intents
|
- name: Download nightly wheels of intents
|
||||||
if: needs.init.outputs.channel == 'dev'
|
if: needs.init.outputs.channel == 'dev'
|
||||||
uses: dawidd6/action-download-artifact@v9
|
uses: dawidd6/action-download-artifact@v11
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
repo: home-assistant/intents-package
|
repo: OHF-Voice/intents-package
|
||||||
branch: main
|
branch: main
|
||||||
workflow: nightly.yaml
|
workflow: nightly.yaml
|
||||||
workflow_conclusion: success
|
workflow_conclusion: success
|
||||||
@ -116,7 +116,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
if: needs.init.outputs.channel == 'dev'
|
if: needs.init.outputs.channel == 'dev'
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
@ -175,7 +175,7 @@ jobs:
|
|||||||
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
|
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
|
||||||
|
|
||||||
- name: Download translations
|
- name: Download translations
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
name: translations
|
name: translations
|
||||||
|
|
||||||
@ -324,7 +324,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
uses: sigstore/cosign-installer@v3.8.1
|
uses: sigstore/cosign-installer@v3.9.1
|
||||||
with:
|
with:
|
||||||
cosign-release: "v2.2.3"
|
cosign-release: "v2.2.3"
|
||||||
|
|
||||||
@ -457,12 +457,12 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
- name: Download translations
|
- name: Download translations
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
name: translations
|
name: translations
|
||||||
|
|
||||||
@ -509,7 +509,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||||
with:
|
with:
|
||||||
context: . # So action will not pull the repository again
|
context: . # So action will not pull the repository again
|
||||||
file: ./script/hassfest/docker/Dockerfile
|
file: ./script/hassfest/docker/Dockerfile
|
||||||
@ -522,7 +522,7 @@ jobs:
|
|||||||
- name: Push Docker image
|
- name: Push Docker image
|
||||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||||
with:
|
with:
|
||||||
context: . # So action will not pull the repository again
|
context: . # So action will not pull the repository again
|
||||||
file: ./script/hassfest/docker/Dockerfile
|
file: ./script/hassfest/docker/Dockerfile
|
||||||
@ -531,7 +531,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate artifact attestation
|
- name: Generate artifact attestation
|
||||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||||
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
|
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
subject-digest: ${{ steps.push.outputs.digest }}
|
||||||
|
142
.github/workflows/ci.yaml
vendored
142
.github/workflows/ci.yaml
vendored
@ -37,10 +37,10 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CACHE_VERSION: 12
|
CACHE_VERSION: 4
|
||||||
UV_CACHE_VERSION: 1
|
UV_CACHE_VERSION: 1
|
||||||
MYPY_CACHE_VERSION: 9
|
MYPY_CACHE_VERSION: 1
|
||||||
HA_SHORT_VERSION: "2025.5"
|
HA_SHORT_VERSION: "2025.8"
|
||||||
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
|
||||||
@ -249,7 +249,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -259,7 +259,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||||
needs.info.outputs.pre-commit_cache_key }}
|
needs.info.outputs.pre-commit_cache_key }}
|
||||||
- name: Create Python virtual environment
|
- name: Create Python virtual environment
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
@ -276,7 +276,7 @@ jobs:
|
|||||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
lookup-only: true
|
lookup-only: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||||
needs.info.outputs.pre-commit_cache_key }}
|
needs.info.outputs.pre-commit_cache_key }}
|
||||||
- name: Install pre-commit dependencies
|
- name: Install pre-commit dependencies
|
||||||
if: steps.cache-precommit.outputs.cache-hit != 'true'
|
if: steps.cache-precommit.outputs.cache-hit != 'true'
|
||||||
@ -294,7 +294,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
@ -306,7 +306,7 @@ jobs:
|
|||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||||
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
|
||||||
@ -315,7 +315,7 @@ jobs:
|
|||||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||||
needs.info.outputs.pre-commit_cache_key }}
|
needs.info.outputs.pre-commit_cache_key }}
|
||||||
- name: Run ruff-format
|
- name: Run ruff-format
|
||||||
run: |
|
run: |
|
||||||
@ -334,7 +334,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
@ -346,7 +346,7 @@ jobs:
|
|||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||||
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
|
||||||
@ -355,12 +355,12 @@ jobs:
|
|||||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||||
needs.info.outputs.pre-commit_cache_key }}
|
needs.info.outputs.pre-commit_cache_key }}
|
||||||
- name: Run ruff
|
- name: Run ruff
|
||||||
run: |
|
run: |
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure
|
pre-commit run --hook-stage manual ruff-check --all-files --show-diff-on-failure
|
||||||
env:
|
env:
|
||||||
RUFF_OUTPUT_FORMAT: github
|
RUFF_OUTPUT_FORMAT: github
|
||||||
|
|
||||||
@ -374,7 +374,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
@ -386,7 +386,7 @@ jobs:
|
|||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||||
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
|
||||||
@ -395,7 +395,7 @@ jobs:
|
|||||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||||
needs.info.outputs.pre-commit_cache_key }}
|
needs.info.outputs.pre-commit_cache_key }}
|
||||||
|
|
||||||
- name: Register yamllint problem matcher
|
- name: Register yamllint problem matcher
|
||||||
@ -484,7 +484,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -501,7 +501,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ 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 uv wheel cache
|
- name: Restore uv wheel cache
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
@ -509,10 +509,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: ${{ env.UV_CACHE_DIR }}
|
path: ${{ env.UV_CACHE_DIR }}
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||||
steps.generate-uv-key.outputs.key }}
|
steps.generate-uv-key.outputs.key }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-uv-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
|
||||||
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
|
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
|
||||||
env.HA_SHORT_VERSION }}-
|
env.HA_SHORT_VERSION }}-
|
||||||
- name: Install additional OS dependencies
|
- name: Install additional OS dependencies
|
||||||
@ -587,7 +587,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -598,7 +598,7 @@ jobs:
|
|||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ 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: Run hassfest
|
- name: Run hassfest
|
||||||
run: |
|
run: |
|
||||||
@ -620,7 +620,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -631,7 +631,7 @@ jobs:
|
|||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ 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: Run gen_requirements_all.py
|
- name: Run gen_requirements_all.py
|
||||||
run: |
|
run: |
|
||||||
@ -653,7 +653,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Dependency review
|
- name: Dependency review
|
||||||
uses: actions/dependency-review-action@v4.6.0
|
uses: actions/dependency-review-action@v4.7.1
|
||||||
with:
|
with:
|
||||||
license-check: false # We use our own license audit checks
|
license-check: false # We use our own license audit checks
|
||||||
|
|
||||||
@ -677,7 +677,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -688,7 +688,7 @@ jobs:
|
|||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ 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: Extract license data
|
- name: Extract license data
|
||||||
run: |
|
run: |
|
||||||
@ -720,7 +720,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -731,7 +731,7 @@ jobs:
|
|||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ 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: Register pylint problem matcher
|
- name: Register pylint problem matcher
|
||||||
run: |
|
run: |
|
||||||
@ -767,7 +767,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -778,7 +778,7 @@ jobs:
|
|||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ 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: Register pylint problem matcher
|
- name: Register pylint problem matcher
|
||||||
run: |
|
run: |
|
||||||
@ -812,7 +812,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -830,17 +830,17 @@ jobs:
|
|||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ 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.3
|
||||||
with:
|
with:
|
||||||
path: .mypy_cache
|
path: .mypy_cache
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||||
steps.generate-mypy-key.outputs.key }}
|
steps.generate-mypy-key.outputs.key }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-mypy-${{
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-mypy-${{
|
||||||
env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{
|
env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{
|
||||||
env.HA_SHORT_VERSION }}-
|
env.HA_SHORT_VERSION }}-
|
||||||
- name: Register mypy problem matcher
|
- name: Register mypy problem matcher
|
||||||
@ -889,7 +889,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -900,7 +900,7 @@ jobs:
|
|||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: >-
|
key: >-
|
||||||
${{ runner.os }}-${{ 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: Run split_tests.py
|
- name: Run split_tests.py
|
||||||
run: |
|
run: |
|
||||||
@ -944,12 +944,13 @@ jobs:
|
|||||||
bluez \
|
bluez \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
libturbojpeg \
|
libturbojpeg \
|
||||||
libgammu-dev
|
libgammu-dev \
|
||||||
|
libxml2-utils
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -959,7 +960,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||||
needs.info.outputs.python_cache_key }}
|
needs.info.outputs.python_cache_key }}
|
||||||
- name: Register Python problem matcher
|
- name: Register Python problem matcher
|
||||||
run: |
|
run: |
|
||||||
@ -968,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.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
name: pytest_buckets
|
name: pytest_buckets
|
||||||
- name: Compile English translations
|
- name: Compile English translations
|
||||||
@ -1019,6 +1021,12 @@ jobs:
|
|||||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||||
path: coverage.xml
|
path: coverage.xml
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
- name: Beautify test results
|
||||||
|
# For easier identification of parsing errors
|
||||||
|
if: needs.info.outputs.skip_coverage != 'true'
|
||||||
|
run: |
|
||||||
|
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||||
|
mv "junit.xml-tmp" "junit.xml"
|
||||||
- name: Upload test results artifact
|
- name: Upload test results artifact
|
||||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v4.6.2
|
||||||
@ -1069,12 +1077,13 @@ jobs:
|
|||||||
bluez \
|
bluez \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
libturbojpeg \
|
libturbojpeg \
|
||||||
libmariadb-dev-compat
|
libmariadb-dev-compat \
|
||||||
|
libxml2-utils
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -1084,7 +1093,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||||
needs.info.outputs.python_cache_key }}
|
needs.info.outputs.python_cache_key }}
|
||||||
- name: Register Python problem matcher
|
- name: Register Python problem matcher
|
||||||
run: |
|
run: |
|
||||||
@ -1152,6 +1162,12 @@ jobs:
|
|||||||
steps.pytest-partial.outputs.mariadb }}
|
steps.pytest-partial.outputs.mariadb }}
|
||||||
path: coverage.xml
|
path: coverage.xml
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
- name: Beautify test results
|
||||||
|
# For easier identification of parsing errors
|
||||||
|
if: needs.info.outputs.skip_coverage != 'true'
|
||||||
|
run: |
|
||||||
|
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||||
|
mv "junit.xml-tmp" "junit.xml"
|
||||||
- name: Upload test results artifact
|
- name: Upload test results artifact
|
||||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v4.6.2
|
||||||
@ -1200,7 +1216,8 @@ jobs:
|
|||||||
sudo apt-get -y install \
|
sudo apt-get -y install \
|
||||||
bluez \
|
bluez \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
libturbojpeg
|
libturbojpeg \
|
||||||
|
libxml2-utils
|
||||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||||
sudo apt-get -y install \
|
sudo apt-get -y install \
|
||||||
postgresql-server-dev-14
|
postgresql-server-dev-14
|
||||||
@ -1208,7 +1225,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -1218,7 +1235,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||||
needs.info.outputs.python_cache_key }}
|
needs.info.outputs.python_cache_key }}
|
||||||
- name: Register Python problem matcher
|
- name: Register Python problem matcher
|
||||||
run: |
|
run: |
|
||||||
@ -1287,6 +1305,12 @@ jobs:
|
|||||||
steps.pytest-partial.outputs.postgresql }}
|
steps.pytest-partial.outputs.postgresql }}
|
||||||
path: coverage.xml
|
path: coverage.xml
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
- name: Beautify test results
|
||||||
|
# For easier identification of parsing errors
|
||||||
|
if: needs.info.outputs.skip_coverage != 'true'
|
||||||
|
run: |
|
||||||
|
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||||
|
mv "junit.xml-tmp" "junit.xml"
|
||||||
- name: Upload test results artifact
|
- name: Upload test results artifact
|
||||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v4.6.2
|
||||||
@ -1312,12 +1336,12 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Download all coverage artifacts
|
- name: Download all coverage artifacts
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
pattern: coverage-*
|
pattern: coverage-*
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: needs.info.outputs.test_full_suite == 'true'
|
if: needs.info.outputs.test_full_suite == 'true'
|
||||||
uses: codecov/codecov-action@v5.4.2
|
uses: codecov/codecov-action@v5.4.3
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
flags: full-suite
|
flags: full-suite
|
||||||
@ -1354,12 +1378,13 @@ jobs:
|
|||||||
bluez \
|
bluez \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
libturbojpeg \
|
libturbojpeg \
|
||||||
libgammu-dev
|
libgammu-dev \
|
||||||
|
libxml2-utils
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -1369,7 +1394,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
key: >-
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||||
needs.info.outputs.python_cache_key }}
|
needs.info.outputs.python_cache_key }}
|
||||||
- name: Register Python problem matcher
|
- name: Register Python problem matcher
|
||||||
run: |
|
run: |
|
||||||
@ -1432,6 +1458,12 @@ jobs:
|
|||||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||||
path: coverage.xml
|
path: coverage.xml
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
- name: Beautify test results
|
||||||
|
# For easier identification of parsing errors
|
||||||
|
if: needs.info.outputs.skip_coverage != 'true'
|
||||||
|
run: |
|
||||||
|
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||||
|
mv "junit.xml-tmp" "junit.xml"
|
||||||
- name: Upload test results artifact
|
- name: Upload test results artifact
|
||||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v4.6.2
|
||||||
@ -1454,12 +1486,12 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Download all coverage artifacts
|
- name: Download all coverage artifacts
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
pattern: coverage-*
|
pattern: coverage-*
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: needs.info.outputs.test_full_suite == 'false'
|
if: needs.info.outputs.test_full_suite == 'false'
|
||||||
uses: codecov/codecov-action@v5.4.2
|
uses: codecov/codecov-action@v5.4.3
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
@ -1479,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.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
pattern: test-results-*
|
pattern: test-results-*
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@ -24,11 +24,11 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3.28.15
|
uses: github/codeql-action/init@v3.29.2
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3.28.15
|
uses: github/codeql-action/analyze@v3.29.2
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
385
.github/workflows/detect-duplicate-issues.yml
vendored
Normal file
385
.github/workflows/detect-duplicate-issues.yml
vendored
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
name: Auto-detect duplicate issues
|
||||||
|
|
||||||
|
# yamllint disable-line rule:truthy
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [labeled]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
models: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
detect-duplicates:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check if integration label was added and extract details
|
||||||
|
id: extract
|
||||||
|
uses: actions/github-script@v7.0.1
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
// Debug: Log the event payload
|
||||||
|
console.log('Event name:', context.eventName);
|
||||||
|
console.log('Event action:', context.payload.action);
|
||||||
|
console.log('Event payload keys:', Object.keys(context.payload));
|
||||||
|
|
||||||
|
// Check the specific label that was added
|
||||||
|
const addedLabel = context.payload.label;
|
||||||
|
if (!addedLabel) {
|
||||||
|
console.log('No label found in labeled event payload');
|
||||||
|
core.setOutput('should_continue', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Label added: ${addedLabel.name}`);
|
||||||
|
|
||||||
|
if (!addedLabel.name.startsWith('integration:')) {
|
||||||
|
console.log('Added label is not an integration label, skipping duplicate detection');
|
||||||
|
core.setOutput('should_continue', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Integration label added: ${addedLabel.name}`);
|
||||||
|
|
||||||
|
let currentIssue;
|
||||||
|
let integrationLabels = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issue = await github.rest.issues.get({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number
|
||||||
|
});
|
||||||
|
|
||||||
|
currentIssue = issue.data;
|
||||||
|
|
||||||
|
// Check if potential-duplicate label already exists
|
||||||
|
const hasPotentialDuplicateLabel = currentIssue.labels
|
||||||
|
.some(label => label.name === 'potential-duplicate');
|
||||||
|
|
||||||
|
if (hasPotentialDuplicateLabel) {
|
||||||
|
console.log('Issue already has potential-duplicate label, skipping duplicate detection');
|
||||||
|
core.setOutput('should_continue', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
integrationLabels = currentIssue.labels
|
||||||
|
.filter(label => label.name.startsWith('integration:'))
|
||||||
|
.map(label => label.name);
|
||||||
|
} catch (error) {
|
||||||
|
core.error(`Failed to fetch issue #${context.payload.issue.number}:`, error.message);
|
||||||
|
core.setOutput('should_continue', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've already posted a duplicate detection comment recently
|
||||||
|
let comments;
|
||||||
|
try {
|
||||||
|
comments = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
per_page: 10
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
core.error('Failed to fetch comments:', error.message);
|
||||||
|
// Continue anyway, worst case we might post a duplicate comment
|
||||||
|
comments = { data: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've already posted a duplicate detection comment
|
||||||
|
const recentDuplicateComment = comments.data.find(comment =>
|
||||||
|
comment.user && comment.user.login === 'github-actions[bot]' &&
|
||||||
|
comment.body.includes('<!-- workflow: detect-duplicate-issues -->')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recentDuplicateComment) {
|
||||||
|
console.log('Already posted duplicate detection comment, skipping');
|
||||||
|
core.setOutput('should_continue', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput('should_continue', 'true');
|
||||||
|
core.setOutput('current_number', currentIssue.number);
|
||||||
|
core.setOutput('current_title', currentIssue.title);
|
||||||
|
core.setOutput('current_body', currentIssue.body);
|
||||||
|
core.setOutput('current_url', currentIssue.html_url);
|
||||||
|
core.setOutput('integration_labels', JSON.stringify(integrationLabels));
|
||||||
|
|
||||||
|
console.log(`Current issue: #${currentIssue.number}`);
|
||||||
|
console.log(`Integration labels: ${integrationLabels.join(', ')}`);
|
||||||
|
|
||||||
|
- name: Fetch similar issues
|
||||||
|
id: fetch_similar
|
||||||
|
if: steps.extract.outputs.should_continue == 'true'
|
||||||
|
uses: actions/github-script@v7.0.1
|
||||||
|
env:
|
||||||
|
INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }}
|
||||||
|
CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const integrationLabels = JSON.parse(process.env.INTEGRATION_LABELS);
|
||||||
|
const currentNumber = parseInt(process.env.CURRENT_NUMBER);
|
||||||
|
|
||||||
|
if (integrationLabels.length === 0) {
|
||||||
|
console.log('No integration labels found, skipping duplicate detection');
|
||||||
|
core.setOutput('has_similar', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use GitHub search API to find issues with matching integration labels
|
||||||
|
console.log(`Searching for issues with integration labels: ${integrationLabels.join(', ')}`);
|
||||||
|
|
||||||
|
// Build search query for issues with any of the current integration labels
|
||||||
|
const labelQueries = integrationLabels.map(label => `label:"${label}"`);
|
||||||
|
|
||||||
|
// Calculate date 6 months ago
|
||||||
|
const sixMonthsAgo = new Date();
|
||||||
|
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||||
|
const dateFilter = `created:>=${sixMonthsAgo.toISOString().split('T')[0]}`;
|
||||||
|
|
||||||
|
let searchQuery;
|
||||||
|
|
||||||
|
if (labelQueries.length === 1) {
|
||||||
|
searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue ${labelQueries[0]} ${dateFilter}`;
|
||||||
|
} else {
|
||||||
|
searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue (${labelQueries.join(' OR ')}) ${dateFilter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Search query: ${searchQuery}`);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await github.rest.search.issuesAndPullRequests({
|
||||||
|
q: searchQuery,
|
||||||
|
per_page: 15,
|
||||||
|
sort: 'updated',
|
||||||
|
order: 'desc'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
core.error('Failed to search for similar issues:', error.message);
|
||||||
|
if (error.status === 403 && error.message.includes('rate limit')) {
|
||||||
|
core.error('GitHub API rate limit exceeded');
|
||||||
|
}
|
||||||
|
core.setOutput('has_similar', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out the current issue, pull requests, and newer issues (higher numbers)
|
||||||
|
const similarIssues = result.data.items
|
||||||
|
.filter(item =>
|
||||||
|
item.number !== currentNumber &&
|
||||||
|
!item.pull_request &&
|
||||||
|
item.number < currentNumber // Only include older issues (lower numbers)
|
||||||
|
)
|
||||||
|
.map(item => ({
|
||||||
|
number: item.number,
|
||||||
|
title: item.title,
|
||||||
|
body: item.body,
|
||||||
|
url: item.html_url,
|
||||||
|
state: item.state,
|
||||||
|
createdAt: item.created_at,
|
||||||
|
updatedAt: item.updated_at,
|
||||||
|
comments: item.comments,
|
||||||
|
labels: item.labels.map(l => l.name)
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`Found ${similarIssues.length} issues with matching integration labels`);
|
||||||
|
console.log('Raw similar issues:', JSON.stringify(similarIssues.slice(0, 3), null, 2));
|
||||||
|
|
||||||
|
if (similarIssues.length === 0) {
|
||||||
|
console.log('No similar issues found, setting has_similar to false');
|
||||||
|
core.setOutput('has_similar', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Similar issues found, setting has_similar to true');
|
||||||
|
core.setOutput('has_similar', 'true');
|
||||||
|
|
||||||
|
// Clean the issue data to prevent JSON parsing issues
|
||||||
|
const cleanedIssues = similarIssues.slice(0, 15).map(item => {
|
||||||
|
// Handle body with improved truncation and null handling
|
||||||
|
let cleanBody = '';
|
||||||
|
if (item.body && typeof item.body === 'string') {
|
||||||
|
// Remove control characters
|
||||||
|
const cleaned = item.body.replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
||||||
|
// Truncate to 1000 characters and add ellipsis if needed
|
||||||
|
cleanBody = cleaned.length > 1000
|
||||||
|
? cleaned.substring(0, 1000) + '...'
|
||||||
|
: cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
number: item.number,
|
||||||
|
title: item.title.replace(/[\u0000-\u001F\u007F-\u009F]/g, ''), // Remove control characters
|
||||||
|
body: cleanBody,
|
||||||
|
url: item.url,
|
||||||
|
state: item.state,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
comments: item.comments,
|
||||||
|
labels: item.labels
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Cleaned issues count: ${cleanedIssues.length}`);
|
||||||
|
console.log('First cleaned issue:', JSON.stringify(cleanedIssues[0], null, 2));
|
||||||
|
|
||||||
|
core.setOutput('similar_issues', JSON.stringify(cleanedIssues));
|
||||||
|
|
||||||
|
- name: Detect duplicates using AI
|
||||||
|
id: ai_detection
|
||||||
|
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||||
|
uses: actions/ai-inference@v1.1.0
|
||||||
|
with:
|
||||||
|
model: openai/gpt-4o
|
||||||
|
system-prompt: |
|
||||||
|
You are a Home Assistant issue duplicate detector. Your task is to identify TRUE DUPLICATES - issues that report the EXACT SAME problem, not just similar or related issues.
|
||||||
|
|
||||||
|
CRITICAL: An issue is ONLY a duplicate if:
|
||||||
|
- It describes the SAME problem with the SAME root cause
|
||||||
|
- Issues about the same integration but different problems are NOT duplicates
|
||||||
|
- Issues with similar symptoms but different causes are NOT duplicates
|
||||||
|
|
||||||
|
Important considerations:
|
||||||
|
- Open issues are more relevant than closed ones for duplicate detection
|
||||||
|
- Recently updated issues may indicate ongoing work or discussion
|
||||||
|
- Issues with more comments are generally more relevant and active
|
||||||
|
- Older closed issues might be resolved differently than newer approaches
|
||||||
|
- Consider the time between issues - very old issues may have different contexts
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. ONLY mark as duplicate if the issues describe IDENTICAL problems
|
||||||
|
2. Look for issues that report the same problem or request the same functionality
|
||||||
|
3. Different error messages = NOT a duplicate (even if same integration)
|
||||||
|
4. For CLOSED issues, only mark as duplicate if they describe the EXACT same problem
|
||||||
|
5. For OPEN issues, use a lower threshold (90%+ similarity)
|
||||||
|
6. Prioritize issues with higher comment counts as they indicate more activity/relevance
|
||||||
|
7. When in doubt, do NOT mark as duplicate
|
||||||
|
8. Return ONLY a JSON array of issue numbers that are duplicates
|
||||||
|
9. If no duplicates are found, return an empty array: []
|
||||||
|
10. Maximum 5 potential duplicates, prioritize open issues with comments
|
||||||
|
11. Consider the age of issues - prefer recent duplicates over very old ones
|
||||||
|
|
||||||
|
Example response format:
|
||||||
|
[1234, 5678, 9012]
|
||||||
|
|
||||||
|
prompt: |
|
||||||
|
Current issue (just created):
|
||||||
|
Title: ${{ steps.extract.outputs.current_title }}
|
||||||
|
Body: ${{ steps.extract.outputs.current_body }}
|
||||||
|
|
||||||
|
Other issues to compare against (each includes state, creation date, last update, and comment count):
|
||||||
|
${{ steps.fetch_similar.outputs.similar_issues }}
|
||||||
|
|
||||||
|
Analyze these issues and identify which ones describe IDENTICAL problems and thus are duplicates of the current issue. When sorting them, consider their state (open/closed), how recently they were updated, and their comment count (higher = more relevant).
|
||||||
|
|
||||||
|
max-tokens: 100
|
||||||
|
|
||||||
|
- name: Post duplicate detection results
|
||||||
|
id: post_results
|
||||||
|
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||||
|
uses: actions/github-script@v7.0.1
|
||||||
|
env:
|
||||||
|
AI_RESPONSE: ${{ steps.ai_detection.outputs.response }}
|
||||||
|
SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const aiResponse = process.env.AI_RESPONSE;
|
||||||
|
|
||||||
|
console.log('Raw AI response:', JSON.stringify(aiResponse));
|
||||||
|
|
||||||
|
let duplicateNumbers = [];
|
||||||
|
try {
|
||||||
|
// Clean the response of any potential control characters
|
||||||
|
const cleanResponse = aiResponse.trim().replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
||||||
|
console.log('Cleaned AI response:', cleanResponse);
|
||||||
|
|
||||||
|
duplicateNumbers = JSON.parse(cleanResponse);
|
||||||
|
|
||||||
|
// Ensure it's an array and contains only numbers
|
||||||
|
if (!Array.isArray(duplicateNumbers)) {
|
||||||
|
console.log('AI response is not an array, trying to extract numbers');
|
||||||
|
const numberMatches = cleanResponse.match(/\d+/g);
|
||||||
|
duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only valid numbers
|
||||||
|
duplicateNumbers = duplicateNumbers.filter(n => typeof n === 'number' && !isNaN(n));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to parse AI response as JSON:', error.message);
|
||||||
|
console.log('Raw response:', aiResponse);
|
||||||
|
|
||||||
|
// Fallback: try to extract numbers from the response
|
||||||
|
const numberMatches = aiResponse.match(/\d+/g);
|
||||||
|
duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : [];
|
||||||
|
console.log('Extracted numbers as fallback:', duplicateNumbers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(duplicateNumbers) || duplicateNumbers.length === 0) {
|
||||||
|
console.log('No duplicates detected by AI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`AI detected ${duplicateNumbers.length} potential duplicates: ${duplicateNumbers.join(', ')}`);
|
||||||
|
|
||||||
|
// Get details of detected duplicates
|
||||||
|
const similarIssues = JSON.parse(process.env.SIMILAR_ISSUES);
|
||||||
|
const duplicates = similarIssues.filter(issue => duplicateNumbers.includes(issue.number));
|
||||||
|
|
||||||
|
if (duplicates.length === 0) {
|
||||||
|
console.log('No matching issues found for detected numbers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create comment with duplicate detection results
|
||||||
|
const duplicateLinks = duplicates.map(issue => `- [#${issue.number}: ${issue.title}](${issue.url})`).join('\n');
|
||||||
|
|
||||||
|
const commentBody = [
|
||||||
|
'<!-- workflow: detect-duplicate-issues -->',
|
||||||
|
'### 🔍 **Potential duplicate detection**',
|
||||||
|
'',
|
||||||
|
'I\'ve analyzed similar issues and found the following potential duplicates:',
|
||||||
|
'',
|
||||||
|
duplicateLinks,
|
||||||
|
'',
|
||||||
|
'**What to do next:**',
|
||||||
|
'1. Please review these issues to see if they match your issue',
|
||||||
|
'2. If you find an existing issue that covers your problem:',
|
||||||
|
' - Consider closing this issue',
|
||||||
|
' - Add your findings or 👍 on the existing issue instead',
|
||||||
|
'3. If your issue is different or adds new aspects, please clarify how it differs',
|
||||||
|
'',
|
||||||
|
'This helps keep our issues organized and ensures similar issues are consolidated for better visibility.',
|
||||||
|
'',
|
||||||
|
'*This message was generated automatically by our duplicate detection system.*'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
body: commentBody
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Posted duplicate detection comment with ${duplicates.length} potential duplicates`);
|
||||||
|
|
||||||
|
// Add the potential-duplicate label
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
labels: ['potential-duplicate']
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Added potential-duplicate label to the issue');
|
||||||
|
} catch (error) {
|
||||||
|
core.error('Failed to post duplicate detection comment or add label:', error.message);
|
||||||
|
if (error.status === 403) {
|
||||||
|
core.error('Permission denied or rate limit exceeded');
|
||||||
|
}
|
||||||
|
// Don't throw - we've done the analysis, just couldn't post the result
|
||||||
|
}
|
193
.github/workflows/detect-non-english-issues.yml
vendored
Normal file
193
.github/workflows/detect-non-english-issues.yml
vendored
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
name: Auto-detect non-English issues
|
||||||
|
|
||||||
|
# yamllint disable-line rule:truthy
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
models: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
detect-language:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check issue language
|
||||||
|
id: detect_language
|
||||||
|
uses: actions/github-script@v7.0.1
|
||||||
|
env:
|
||||||
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||||
|
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||||
|
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||||
|
ISSUE_USER_TYPE: ${{ github.event.issue.user.type }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
// Get the issue details from environment variables
|
||||||
|
const issueNumber = process.env.ISSUE_NUMBER;
|
||||||
|
const issueTitle = process.env.ISSUE_TITLE || '';
|
||||||
|
const issueBody = process.env.ISSUE_BODY || '';
|
||||||
|
const userType = process.env.ISSUE_USER_TYPE;
|
||||||
|
|
||||||
|
// Skip language detection for bot users
|
||||||
|
if (userType === 'Bot') {
|
||||||
|
console.log('Skipping language detection for bot user');
|
||||||
|
core.setOutput('should_continue', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Checking language for issue #${issueNumber}`);
|
||||||
|
console.log(`Title: ${issueTitle}`);
|
||||||
|
|
||||||
|
// Combine title and body for language detection
|
||||||
|
const fullText = `${issueTitle}\n\n${issueBody}`;
|
||||||
|
|
||||||
|
// Check if the text is too short to reliably detect language
|
||||||
|
if (fullText.trim().length < 20) {
|
||||||
|
console.log('Text too short for reliable language detection');
|
||||||
|
core.setOutput('should_continue', 'false'); // Skip processing for very short text
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput('issue_number', issueNumber);
|
||||||
|
core.setOutput('issue_text', fullText);
|
||||||
|
core.setOutput('should_continue', 'true');
|
||||||
|
|
||||||
|
- name: Detect language using AI
|
||||||
|
id: ai_language_detection
|
||||||
|
if: steps.detect_language.outputs.should_continue == 'true'
|
||||||
|
uses: actions/ai-inference@v1.1.0
|
||||||
|
with:
|
||||||
|
model: openai/gpt-4o-mini
|
||||||
|
system-prompt: |
|
||||||
|
You are a language detection system. Your task is to determine if the provided text is written in English or another language.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Analyze the text and determine the primary language of the USER'S DESCRIPTION only
|
||||||
|
2. IGNORE markdown headers (lines starting with #, ##, ###, etc.) as these are from issue templates, not user input
|
||||||
|
3. IGNORE all code blocks (text between ``` or ` markers) as they may contain system-generated error messages in other languages
|
||||||
|
4. IGNORE error messages, logs, and system output even if not in code blocks - these often appear in the user's system language
|
||||||
|
5. Consider technical terms, code snippets, URLs, and file paths as neutral (they don't indicate non-English)
|
||||||
|
6. Focus ONLY on the actual sentences and descriptions written by the user explaining their issue
|
||||||
|
7. If the user's explanation/description is in English but includes non-English error messages or logs, consider it ENGLISH
|
||||||
|
8. Return ONLY a JSON object with two fields:
|
||||||
|
- "is_english": boolean (true if the user's description is primarily in English, false otherwise)
|
||||||
|
- "detected_language": string (the name of the detected language, e.g., "English", "Spanish", "Chinese", etc.)
|
||||||
|
9. Be lenient - if the user's explanation is in English with non-English system output, it's still English
|
||||||
|
10. Common programming terms, error messages, and technical jargon should not be considered as non-English
|
||||||
|
11. If you cannot reliably determine the language, set detected_language to "undefined"
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
{"is_english": false, "detected_language": "Spanish"}
|
||||||
|
|
||||||
|
prompt: |
|
||||||
|
Please analyze the following issue text and determine if it is written in English:
|
||||||
|
|
||||||
|
${{ steps.detect_language.outputs.issue_text }}
|
||||||
|
|
||||||
|
max-tokens: 50
|
||||||
|
|
||||||
|
- name: Process non-English issues
|
||||||
|
if: steps.detect_language.outputs.should_continue == 'true'
|
||||||
|
uses: actions/github-script@v7.0.1
|
||||||
|
env:
|
||||||
|
AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }}
|
||||||
|
ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
|
||||||
|
const aiResponse = process.env.AI_RESPONSE;
|
||||||
|
|
||||||
|
console.log('AI language detection response:', aiResponse);
|
||||||
|
|
||||||
|
let languageResult;
|
||||||
|
try {
|
||||||
|
languageResult = JSON.parse(aiResponse.trim());
|
||||||
|
|
||||||
|
// Validate the response structure
|
||||||
|
if (!languageResult || typeof languageResult.is_english !== 'boolean') {
|
||||||
|
throw new Error('Invalid response structure');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
core.error(`Failed to parse AI response: ${error.message}`);
|
||||||
|
console.log('Raw AI response:', aiResponse);
|
||||||
|
|
||||||
|
// Log more details for debugging
|
||||||
|
core.warning('Defaulting to English due to parsing error');
|
||||||
|
|
||||||
|
// Default to English if we can't parse the response
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (languageResult.is_english) {
|
||||||
|
console.log('Issue is in English, no action needed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If language is undefined or not detected, skip processing
|
||||||
|
if (!languageResult.detected_language || languageResult.detected_language === 'undefined') {
|
||||||
|
console.log('Language could not be determined, skipping processing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Issue detected as non-English: ${languageResult.detected_language}`);
|
||||||
|
|
||||||
|
// Post comment explaining the language requirement
|
||||||
|
const commentBody = [
|
||||||
|
'<!-- workflow: detect-non-english-issues -->',
|
||||||
|
'### 🌐 Non-English issue detected',
|
||||||
|
'',
|
||||||
|
`This issue appears to be written in **${languageResult.detected_language}** rather than English.`,
|
||||||
|
'',
|
||||||
|
'The Home Assistant project uses English as the primary language for issues to ensure that everyone in our international community can participate and help resolve issues. This allows any of our thousands of contributors to jump in and provide assistance.',
|
||||||
|
'',
|
||||||
|
'**What to do:**',
|
||||||
|
'1. Re-create the issue using the English language',
|
||||||
|
'2. If you need help with translation, consider using:',
|
||||||
|
' - Translation tools like Google Translate',
|
||||||
|
' - AI assistants like ChatGPT or Claude',
|
||||||
|
'',
|
||||||
|
'This helps our community provide the best possible support and ensures your issue gets the attention it deserves from our global contributor base.',
|
||||||
|
'',
|
||||||
|
'Thank you for your understanding! 🙏'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add comment
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
body: commentBody
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Posted language requirement comment');
|
||||||
|
|
||||||
|
// Add non-english label
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
labels: ['non-english']
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Added non-english label');
|
||||||
|
|
||||||
|
// Close the issue
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
state: 'closed',
|
||||||
|
state_reason: 'not_planned'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Closed the issue');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
core.error('Failed to process non-English issue:', error.message);
|
||||||
|
if (error.status === 403) {
|
||||||
|
core.error('Permission denied or rate limit exceeded');
|
||||||
|
}
|
||||||
|
}
|
84
.github/workflows/restrict-task-creation.yml
vendored
Normal file
84
.github/workflows/restrict-task-creation.yml
vendored
Normal 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.issue_type == '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']
|
||||||
|
});
|
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@ -22,7 +22,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
|
16
.github/workflows/wheels.yml
vendored
16
.github/workflows/wheels.yml
vendored
@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v5.5.0
|
uses: actions/setup-python@v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@ -138,17 +138,17 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Download env_file
|
- name: Download env_file
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
name: env_file
|
name: env_file
|
||||||
|
|
||||||
- name: Download build_constraints
|
- name: Download build_constraints
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
name: build_constraints
|
name: build_constraints
|
||||||
|
|
||||||
- name: Download requirements_diff
|
- name: Download requirements_diff
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
name: requirements_diff
|
name: requirements_diff
|
||||||
|
|
||||||
@ -187,22 +187,22 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Download env_file
|
- name: Download env_file
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
name: env_file
|
name: env_file
|
||||||
|
|
||||||
- name: Download build_constraints
|
- name: Download build_constraints
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
name: build_constraints
|
name: build_constraints
|
||||||
|
|
||||||
- name: Download requirements_diff
|
- name: Download requirements_diff
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
name: requirements_diff
|
name: requirements_diff
|
||||||
|
|
||||||
- name: Download requirements_all_wheels
|
- name: Download requirements_all_wheels
|
||||||
uses: actions/download-artifact@v4.2.1
|
uses: actions/download-artifact@v4.3.0
|
||||||
with:
|
with:
|
||||||
name: requirements_all_wheels
|
name: requirements_all_wheels
|
||||||
|
|
||||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -138,3 +138,7 @@ tmp_cache
|
|||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.11.0
|
rev: v0.12.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff-check
|
||||||
args:
|
args:
|
||||||
- --fix
|
- --fix
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
@ -30,7 +30,7 @@ repos:
|
|||||||
- --branch=master
|
- --branch=master
|
||||||
- --branch=rc
|
- --branch=rc
|
||||||
- repo: https://github.com/adrienverge/yamllint.git
|
- repo: https://github.com/adrienverge/yamllint.git
|
||||||
rev: v1.35.1
|
rev: v1.37.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: yamllint
|
- id: yamllint
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
|
@ -65,7 +65,9 @@ homeassistant.components.aladdin_connect.*
|
|||||||
homeassistant.components.alarm_control_panel.*
|
homeassistant.components.alarm_control_panel.*
|
||||||
homeassistant.components.alert.*
|
homeassistant.components.alert.*
|
||||||
homeassistant.components.alexa.*
|
homeassistant.components.alexa.*
|
||||||
|
homeassistant.components.alexa_devices.*
|
||||||
homeassistant.components.alpha_vantage.*
|
homeassistant.components.alpha_vantage.*
|
||||||
|
homeassistant.components.altruist.*
|
||||||
homeassistant.components.amazon_polly.*
|
homeassistant.components.amazon_polly.*
|
||||||
homeassistant.components.amberelectric.*
|
homeassistant.components.amberelectric.*
|
||||||
homeassistant.components.ambient_network.*
|
homeassistant.components.ambient_network.*
|
||||||
@ -270,6 +272,7 @@ homeassistant.components.image_processing.*
|
|||||||
homeassistant.components.image_upload.*
|
homeassistant.components.image_upload.*
|
||||||
homeassistant.components.imap.*
|
homeassistant.components.imap.*
|
||||||
homeassistant.components.imgw_pib.*
|
homeassistant.components.imgw_pib.*
|
||||||
|
homeassistant.components.immich.*
|
||||||
homeassistant.components.incomfort.*
|
homeassistant.components.incomfort.*
|
||||||
homeassistant.components.input_button.*
|
homeassistant.components.input_button.*
|
||||||
homeassistant.components.input_select.*
|
homeassistant.components.input_select.*
|
||||||
@ -332,6 +335,7 @@ homeassistant.components.media_player.*
|
|||||||
homeassistant.components.media_source.*
|
homeassistant.components.media_source.*
|
||||||
homeassistant.components.met_eireann.*
|
homeassistant.components.met_eireann.*
|
||||||
homeassistant.components.metoffice.*
|
homeassistant.components.metoffice.*
|
||||||
|
homeassistant.components.miele.*
|
||||||
homeassistant.components.mikrotik.*
|
homeassistant.components.mikrotik.*
|
||||||
homeassistant.components.min_max.*
|
homeassistant.components.min_max.*
|
||||||
homeassistant.components.minecraft_server.*
|
homeassistant.components.minecraft_server.*
|
||||||
@ -363,6 +367,7 @@ homeassistant.components.no_ip.*
|
|||||||
homeassistant.components.nordpool.*
|
homeassistant.components.nordpool.*
|
||||||
homeassistant.components.notify.*
|
homeassistant.components.notify.*
|
||||||
homeassistant.components.notion.*
|
homeassistant.components.notion.*
|
||||||
|
homeassistant.components.ntfy.*
|
||||||
homeassistant.components.number.*
|
homeassistant.components.number.*
|
||||||
homeassistant.components.nut.*
|
homeassistant.components.nut.*
|
||||||
homeassistant.components.ohme.*
|
homeassistant.components.ohme.*
|
||||||
@ -376,6 +381,7 @@ 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.*
|
||||||
@ -383,8 +389,10 @@ homeassistant.components.overseerr.*
|
|||||||
homeassistant.components.p1_monitor.*
|
homeassistant.components.p1_monitor.*
|
||||||
homeassistant.components.pandora.*
|
homeassistant.components.pandora.*
|
||||||
homeassistant.components.panel_custom.*
|
homeassistant.components.panel_custom.*
|
||||||
|
homeassistant.components.paperless_ngx.*
|
||||||
homeassistant.components.peblar.*
|
homeassistant.components.peblar.*
|
||||||
homeassistant.components.peco.*
|
homeassistant.components.peco.*
|
||||||
|
homeassistant.components.pegel_online.*
|
||||||
homeassistant.components.persistent_notification.*
|
homeassistant.components.persistent_notification.*
|
||||||
homeassistant.components.person.*
|
homeassistant.components.person.*
|
||||||
homeassistant.components.pi_hole.*
|
homeassistant.components.pi_hole.*
|
||||||
@ -431,7 +439,6 @@ homeassistant.components.roku.*
|
|||||||
homeassistant.components.romy.*
|
homeassistant.components.romy.*
|
||||||
homeassistant.components.rpi_power.*
|
homeassistant.components.rpi_power.*
|
||||||
homeassistant.components.rss_feed_template.*
|
homeassistant.components.rss_feed_template.*
|
||||||
homeassistant.components.rtsp_to_webrtc.*
|
|
||||||
homeassistant.components.russound_rio.*
|
homeassistant.components.russound_rio.*
|
||||||
homeassistant.components.ruuvi_gateway.*
|
homeassistant.components.ruuvi_gateway.*
|
||||||
homeassistant.components.ruuvitag_ble.*
|
homeassistant.components.ruuvitag_ble.*
|
||||||
@ -461,6 +468,7 @@ homeassistant.components.slack.*
|
|||||||
homeassistant.components.sleepiq.*
|
homeassistant.components.sleepiq.*
|
||||||
homeassistant.components.smhi.*
|
homeassistant.components.smhi.*
|
||||||
homeassistant.components.smlight.*
|
homeassistant.components.smlight.*
|
||||||
|
homeassistant.components.smtp.*
|
||||||
homeassistant.components.snooz.*
|
homeassistant.components.snooz.*
|
||||||
homeassistant.components.solarlog.*
|
homeassistant.components.solarlog.*
|
||||||
homeassistant.components.sonarr.*
|
homeassistant.components.sonarr.*
|
||||||
@ -496,6 +504,7 @@ homeassistant.components.tautulli.*
|
|||||||
homeassistant.components.tcp.*
|
homeassistant.components.tcp.*
|
||||||
homeassistant.components.technove.*
|
homeassistant.components.technove.*
|
||||||
homeassistant.components.tedee.*
|
homeassistant.components.tedee.*
|
||||||
|
homeassistant.components.telegram_bot.*
|
||||||
homeassistant.components.text.*
|
homeassistant.components.text.*
|
||||||
homeassistant.components.thethingsnetwork.*
|
homeassistant.components.thethingsnetwork.*
|
||||||
homeassistant.components.threshold.*
|
homeassistant.components.threshold.*
|
||||||
@ -526,6 +535,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.*
|
||||||
|
2
.vscode/tasks.json
vendored
2
.vscode/tasks.json
vendored
@ -45,7 +45,7 @@
|
|||||||
{
|
{
|
||||||
"label": "Ruff",
|
"label": "Ruff",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "pre-commit run ruff --all-files",
|
"command": "pre-commit run ruff-check --all-files",
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "test",
|
"kind": "test",
|
||||||
"isDefault": true
|
"isDefault": true
|
||||||
|
95
CODEOWNERS
generated
95
CODEOWNERS
generated
@ -46,8 +46,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/accuweather/ @bieniu
|
/tests/components/accuweather/ @bieniu
|
||||||
/homeassistant/components/acmeda/ @atmurray
|
/homeassistant/components/acmeda/ @atmurray
|
||||||
/tests/components/acmeda/ @atmurray
|
/tests/components/acmeda/ @atmurray
|
||||||
/homeassistant/components/adax/ @danielhiversen
|
/homeassistant/components/adax/ @danielhiversen @lazytarget
|
||||||
/tests/components/adax/ @danielhiversen
|
/tests/components/adax/ @danielhiversen @lazytarget
|
||||||
/homeassistant/components/adguard/ @frenck
|
/homeassistant/components/adguard/ @frenck
|
||||||
/tests/components/adguard/ @frenck
|
/tests/components/adguard/ @frenck
|
||||||
/homeassistant/components/ads/ @mrpasztoradam
|
/homeassistant/components/ads/ @mrpasztoradam
|
||||||
@ -57,6 +57,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/aemet/ @Noltari
|
/tests/components/aemet/ @Noltari
|
||||||
/homeassistant/components/agent_dvr/ @ispysoftware
|
/homeassistant/components/agent_dvr/ @ispysoftware
|
||||||
/tests/components/agent_dvr/ @ispysoftware
|
/tests/components/agent_dvr/ @ispysoftware
|
||||||
|
/homeassistant/components/ai_task/ @home-assistant/core
|
||||||
|
/tests/components/ai_task/ @home-assistant/core
|
||||||
/homeassistant/components/air_quality/ @home-assistant/core
|
/homeassistant/components/air_quality/ @home-assistant/core
|
||||||
/tests/components/air_quality/ @home-assistant/core
|
/tests/components/air_quality/ @home-assistant/core
|
||||||
/homeassistant/components/airgradient/ @airgradienthq @joostlek
|
/homeassistant/components/airgradient/ @airgradienthq @joostlek
|
||||||
@ -89,6 +91,10 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/alert/ @home-assistant/core @frenck
|
/tests/components/alert/ @home-assistant/core @frenck
|
||||||
/homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
/homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
||||||
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
||||||
|
/homeassistant/components/alexa_devices/ @chemelli74
|
||||||
|
/tests/components/alexa_devices/ @chemelli74
|
||||||
|
/homeassistant/components/altruist/ @airalab @LoSk-p
|
||||||
|
/tests/components/altruist/ @airalab @LoSk-p
|
||||||
/homeassistant/components/amazon_polly/ @jschlyter
|
/homeassistant/components/amazon_polly/ @jschlyter
|
||||||
/homeassistant/components/amberelectric/ @madpilot
|
/homeassistant/components/amberelectric/ @madpilot
|
||||||
/tests/components/amberelectric/ @madpilot
|
/tests/components/amberelectric/ @madpilot
|
||||||
@ -171,6 +177,8 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/avea/ @pattyland
|
/homeassistant/components/avea/ @pattyland
|
||||||
/homeassistant/components/awair/ @ahayworth @danielsjf
|
/homeassistant/components/awair/ @ahayworth @danielsjf
|
||||||
/tests/components/awair/ @ahayworth @danielsjf
|
/tests/components/awair/ @ahayworth @danielsjf
|
||||||
|
/homeassistant/components/aws_s3/ @tomasbedrich
|
||||||
|
/tests/components/aws_s3/ @tomasbedrich
|
||||||
/homeassistant/components/axis/ @Kane610
|
/homeassistant/components/axis/ @Kane610
|
||||||
/tests/components/axis/ @Kane610
|
/tests/components/axis/ @Kane610
|
||||||
/homeassistant/components/azure_data_explorer/ @kaareseras
|
/homeassistant/components/azure_data_explorer/ @kaareseras
|
||||||
@ -200,8 +208,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/blebox/ @bbx-a @swistakm
|
/tests/components/blebox/ @bbx-a @swistakm
|
||||||
/homeassistant/components/blink/ @fronzbot @mkmer
|
/homeassistant/components/blink/ @fronzbot @mkmer
|
||||||
/tests/components/blink/ @fronzbot @mkmer
|
/tests/components/blink/ @fronzbot @mkmer
|
||||||
/homeassistant/components/blue_current/ @Floris272 @gleeuwen
|
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||||
/tests/components/blue_current/ @Floris272 @gleeuwen
|
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||||
/homeassistant/components/bluemaestro/ @bdraco
|
/homeassistant/components/bluemaestro/ @bdraco
|
||||||
/tests/components/bluemaestro/ @bdraco
|
/tests/components/bluemaestro/ @bdraco
|
||||||
/homeassistant/components/blueprint/ @home-assistant/core
|
/homeassistant/components/blueprint/ @home-assistant/core
|
||||||
@ -301,6 +309,7 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/crownstone/ @Crownstone @RicArch97
|
/homeassistant/components/crownstone/ @Crownstone @RicArch97
|
||||||
/tests/components/crownstone/ @Crownstone @RicArch97
|
/tests/components/crownstone/ @Crownstone @RicArch97
|
||||||
/homeassistant/components/cups/ @fabaff
|
/homeassistant/components/cups/ @fabaff
|
||||||
|
/tests/components/cups/ @fabaff
|
||||||
/homeassistant/components/daikin/ @fredrike
|
/homeassistant/components/daikin/ @fredrike
|
||||||
/tests/components/daikin/ @fredrike
|
/tests/components/daikin/ @fredrike
|
||||||
/homeassistant/components/date/ @home-assistant/core
|
/homeassistant/components/date/ @home-assistant/core
|
||||||
@ -322,8 +331,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/demo/ @home-assistant/core
|
/tests/components/demo/ @home-assistant/core
|
||||||
/homeassistant/components/denonavr/ @ol-iver @starkillerOG
|
/homeassistant/components/denonavr/ @ol-iver @starkillerOG
|
||||||
/tests/components/denonavr/ @ol-iver @starkillerOG
|
/tests/components/denonavr/ @ol-iver @starkillerOG
|
||||||
/homeassistant/components/derivative/ @afaucogney
|
/homeassistant/components/derivative/ @afaucogney @karwosts
|
||||||
/tests/components/derivative/ @afaucogney
|
/tests/components/derivative/ @afaucogney @karwosts
|
||||||
/homeassistant/components/devialet/ @fwestenberg
|
/homeassistant/components/devialet/ @fwestenberg
|
||||||
/tests/components/devialet/ @fwestenberg
|
/tests/components/devialet/ @fwestenberg
|
||||||
/homeassistant/components/device_automation/ @home-assistant/core
|
/homeassistant/components/device_automation/ @home-assistant/core
|
||||||
@ -443,8 +452,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
|
||||||
@ -453,8 +462,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/evil_genius_labs/ @balloob
|
/tests/components/evil_genius_labs/ @balloob
|
||||||
/homeassistant/components/evohome/ @zxdavb
|
/homeassistant/components/evohome/ @zxdavb
|
||||||
/tests/components/evohome/ @zxdavb
|
/tests/components/evohome/ @zxdavb
|
||||||
/homeassistant/components/ezviz/ @RenierM26 @baqs
|
/homeassistant/components/ezviz/ @RenierM26
|
||||||
/tests/components/ezviz/ @RenierM26 @baqs
|
/tests/components/ezviz/ @RenierM26
|
||||||
/homeassistant/components/faa_delays/ @ntilley905
|
/homeassistant/components/faa_delays/ @ntilley905
|
||||||
/tests/components/faa_delays/ @ntilley905
|
/tests/components/faa_delays/ @ntilley905
|
||||||
/homeassistant/components/fan/ @home-assistant/core
|
/homeassistant/components/fan/ @home-assistant/core
|
||||||
@ -708,6 +717,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/imeon_inverter/ @Imeon-Energy
|
/tests/components/imeon_inverter/ @Imeon-Energy
|
||||||
/homeassistant/components/imgw_pib/ @bieniu
|
/homeassistant/components/imgw_pib/ @bieniu
|
||||||
/tests/components/imgw_pib/ @bieniu
|
/tests/components/imgw_pib/ @bieniu
|
||||||
|
/homeassistant/components/immich/ @mib1185
|
||||||
|
/tests/components/immich/ @mib1185
|
||||||
/homeassistant/components/improv_ble/ @emontnemery
|
/homeassistant/components/improv_ble/ @emontnemery
|
||||||
/tests/components/improv_ble/ @emontnemery
|
/tests/components/improv_ble/ @emontnemery
|
||||||
/homeassistant/components/incomfort/ @jbouwh
|
/homeassistant/components/incomfort/ @jbouwh
|
||||||
@ -777,8 +788,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/jellyfin/ @RunC0deRun @ctalkington
|
/tests/components/jellyfin/ @RunC0deRun @ctalkington
|
||||||
/homeassistant/components/jewish_calendar/ @tsvi
|
/homeassistant/components/jewish_calendar/ @tsvi
|
||||||
/tests/components/jewish_calendar/ @tsvi
|
/tests/components/jewish_calendar/ @tsvi
|
||||||
/homeassistant/components/juicenet/ @jesserockz
|
|
||||||
/tests/components/juicenet/ @jesserockz
|
|
||||||
/homeassistant/components/justnimbus/ @kvanzuijlen
|
/homeassistant/components/justnimbus/ @kvanzuijlen
|
||||||
/tests/components/justnimbus/ @kvanzuijlen
|
/tests/components/justnimbus/ @kvanzuijlen
|
||||||
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
|
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
|
||||||
@ -1051,6 +1060,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/nsw_fuel_station/ @nickw444
|
/tests/components/nsw_fuel_station/ @nickw444
|
||||||
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
|
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
|
||||||
/tests/components/nsw_rural_fire_service_feed/ @exxamalte
|
/tests/components/nsw_rural_fire_service_feed/ @exxamalte
|
||||||
|
/homeassistant/components/ntfy/ @tr4nt0r
|
||||||
|
/tests/components/ntfy/ @tr4nt0r
|
||||||
/homeassistant/components/nuheat/ @tstabrawa
|
/homeassistant/components/nuheat/ @tstabrawa
|
||||||
/tests/components/nuheat/ @tstabrawa
|
/tests/components/nuheat/ @tstabrawa
|
||||||
/homeassistant/components/nuki/ @pschmitt @pvizeli @pree
|
/homeassistant/components/nuki/ @pschmitt @pvizeli @pree
|
||||||
@ -1079,8 +1090,6 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/ombi/ @larssont
|
/homeassistant/components/ombi/ @larssont
|
||||||
/homeassistant/components/onboarding/ @home-assistant/core
|
/homeassistant/components/onboarding/ @home-assistant/core
|
||||||
/tests/components/onboarding/ @home-assistant/core
|
/tests/components/onboarding/ @home-assistant/core
|
||||||
/homeassistant/components/oncue/ @bdraco @peterager
|
|
||||||
/tests/components/oncue/ @bdraco @peterager
|
|
||||||
/homeassistant/components/ondilo_ico/ @JeromeHXP
|
/homeassistant/components/ondilo_ico/ @JeromeHXP
|
||||||
/tests/components/ondilo_ico/ @JeromeHXP
|
/tests/components/ondilo_ico/ @JeromeHXP
|
||||||
/homeassistant/components/onedrive/ @zweckj
|
/homeassistant/components/onedrive/ @zweckj
|
||||||
@ -1109,8 +1118,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/opentherm_gw/ @mvn23
|
/tests/components/opentherm_gw/ @mvn23
|
||||||
/homeassistant/components/openuv/ @bachya
|
/homeassistant/components/openuv/ @bachya
|
||||||
/tests/components/openuv/ @bachya
|
/tests/components/openuv/ @bachya
|
||||||
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi
|
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
|
||||||
/tests/components/openweathermap/ @fabaff @freekode @nzapponi
|
/tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
|
||||||
/homeassistant/components/opnsense/ @mtreinish
|
/homeassistant/components/opnsense/ @mtreinish
|
||||||
/tests/components/opnsense/ @mtreinish
|
/tests/components/opnsense/ @mtreinish
|
||||||
/homeassistant/components/opower/ @tronikos
|
/homeassistant/components/opower/ @tronikos
|
||||||
@ -1136,6 +1145,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/palazzetti/ @dotvav
|
/tests/components/palazzetti/ @dotvav
|
||||||
/homeassistant/components/panel_custom/ @home-assistant/frontend
|
/homeassistant/components/panel_custom/ @home-assistant/frontend
|
||||||
/tests/components/panel_custom/ @home-assistant/frontend
|
/tests/components/panel_custom/ @home-assistant/frontend
|
||||||
|
/homeassistant/components/paperless_ngx/ @fvgarrel
|
||||||
|
/tests/components/paperless_ngx/ @fvgarrel
|
||||||
/homeassistant/components/peblar/ @frenck
|
/homeassistant/components/peblar/ @frenck
|
||||||
/tests/components/peblar/ @frenck
|
/tests/components/peblar/ @frenck
|
||||||
/homeassistant/components/peco/ @IceBotYT
|
/homeassistant/components/peco/ @IceBotYT
|
||||||
@ -1158,6 +1169,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 @tr4nt0r
|
||||||
|
/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
|
||||||
@ -1174,6 +1187,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||||
/homeassistant/components/private_ble_device/ @Jc2k
|
/homeassistant/components/private_ble_device/ @Jc2k
|
||||||
/tests/components/private_ble_device/ @Jc2k
|
/tests/components/private_ble_device/ @Jc2k
|
||||||
|
/homeassistant/components/probe_plus/ @pantherale0
|
||||||
|
/tests/components/probe_plus/ @pantherale0
|
||||||
/homeassistant/components/profiler/ @bdraco
|
/homeassistant/components/profiler/ @bdraco
|
||||||
/tests/components/profiler/ @bdraco
|
/tests/components/profiler/ @bdraco
|
||||||
/homeassistant/components/progettihwsw/ @ardaseremet
|
/homeassistant/components/progettihwsw/ @ardaseremet
|
||||||
@ -1220,6 +1235,7 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/qnap_qsw/ @Noltari
|
/homeassistant/components/qnap_qsw/ @Noltari
|
||||||
/tests/components/qnap_qsw/ @Noltari
|
/tests/components/qnap_qsw/ @Noltari
|
||||||
/homeassistant/components/quantum_gateway/ @cisasteelersfan
|
/homeassistant/components/quantum_gateway/ @cisasteelersfan
|
||||||
|
/tests/components/quantum_gateway/ @cisasteelersfan
|
||||||
/homeassistant/components/qvr_pro/ @oblogic7
|
/homeassistant/components/qvr_pro/ @oblogic7
|
||||||
/homeassistant/components/qwikswitch/ @kellerza
|
/homeassistant/components/qwikswitch/ @kellerza
|
||||||
/tests/components/qwikswitch/ @kellerza
|
/tests/components/qwikswitch/ @kellerza
|
||||||
@ -1258,10 +1274,12 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/recovery_mode/ @home-assistant/core
|
/tests/components/recovery_mode/ @home-assistant/core
|
||||||
/homeassistant/components/refoss/ @ashionky
|
/homeassistant/components/refoss/ @ashionky
|
||||||
/tests/components/refoss/ @ashionky
|
/tests/components/refoss/ @ashionky
|
||||||
|
/homeassistant/components/rehlko/ @bdraco @peterager
|
||||||
|
/tests/components/rehlko/ @bdraco @peterager
|
||||||
/homeassistant/components/remote/ @home-assistant/core
|
/homeassistant/components/remote/ @home-assistant/core
|
||||||
/tests/components/remote/ @home-assistant/core
|
/tests/components/remote/ @home-assistant/core
|
||||||
/homeassistant/components/remote_calendar/ @Thomas55555
|
/homeassistant/components/remote_calendar/ @Thomas55555 @allenporter
|
||||||
/tests/components/remote_calendar/ @Thomas55555
|
/tests/components/remote_calendar/ @Thomas55555 @allenporter
|
||||||
/homeassistant/components/renault/ @epenet
|
/homeassistant/components/renault/ @epenet
|
||||||
/tests/components/renault/ @epenet
|
/tests/components/renault/ @epenet
|
||||||
/homeassistant/components/renson/ @jimmyd-be
|
/homeassistant/components/renson/ @jimmyd-be
|
||||||
@ -1303,8 +1321,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/rpi_power/ @shenxn @swetoast
|
/tests/components/rpi_power/ @shenxn @swetoast
|
||||||
/homeassistant/components/rss_feed_template/ @home-assistant/core
|
/homeassistant/components/rss_feed_template/ @home-assistant/core
|
||||||
/tests/components/rss_feed_template/ @home-assistant/core
|
/tests/components/rss_feed_template/ @home-assistant/core
|
||||||
/homeassistant/components/rtsp_to_webrtc/ @allenporter
|
|
||||||
/tests/components/rtsp_to_webrtc/ @allenporter
|
|
||||||
/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
|
/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
|
||||||
/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
|
/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
|
||||||
/homeassistant/components/russound_rio/ @noahhusby
|
/homeassistant/components/russound_rio/ @noahhusby
|
||||||
@ -1408,6 +1424,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/sma/ @kellerza @rklomp @erwindouna
|
/tests/components/sma/ @kellerza @rklomp @erwindouna
|
||||||
/homeassistant/components/smappee/ @bsmappee
|
/homeassistant/components/smappee/ @bsmappee
|
||||||
/tests/components/smappee/ @bsmappee
|
/tests/components/smappee/ @bsmappee
|
||||||
|
/homeassistant/components/smarla/ @explicatis @rlint-explicatis
|
||||||
|
/tests/components/smarla/ @explicatis @rlint-explicatis
|
||||||
/homeassistant/components/smart_meter_texas/ @grahamwetzler
|
/homeassistant/components/smart_meter_texas/ @grahamwetzler
|
||||||
/tests/components/smart_meter_texas/ @grahamwetzler
|
/tests/components/smart_meter_texas/ @grahamwetzler
|
||||||
/homeassistant/components/smartthings/ @joostlek
|
/homeassistant/components/smartthings/ @joostlek
|
||||||
@ -1437,8 +1455,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/solarlog/ @Ernst79 @dontinelli
|
/tests/components/solarlog/ @Ernst79 @dontinelli
|
||||||
/homeassistant/components/solax/ @squishykid @Darsstar
|
/homeassistant/components/solax/ @squishykid @Darsstar
|
||||||
/tests/components/solax/ @squishykid @Darsstar
|
/tests/components/solax/ @squishykid @Darsstar
|
||||||
/homeassistant/components/soma/ @ratsept @sebfortier2288
|
/homeassistant/components/soma/ @ratsept
|
||||||
/tests/components/soma/ @ratsept @sebfortier2288
|
/tests/components/soma/ @ratsept
|
||||||
/homeassistant/components/sonarr/ @ctalkington
|
/homeassistant/components/sonarr/ @ctalkington
|
||||||
/tests/components/sonarr/ @ctalkington
|
/tests/components/sonarr/ @ctalkington
|
||||||
/homeassistant/components/songpal/ @rytilahti @shenxn
|
/homeassistant/components/songpal/ @rytilahti @shenxn
|
||||||
@ -1470,7 +1488,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/steam_online/ @tkdrob
|
/tests/components/steam_online/ @tkdrob
|
||||||
/homeassistant/components/steamist/ @bdraco
|
/homeassistant/components/steamist/ @bdraco
|
||||||
/tests/components/steamist/ @bdraco
|
/tests/components/steamist/ @bdraco
|
||||||
/homeassistant/components/stiebel_eltron/ @fucm
|
/homeassistant/components/stiebel_eltron/ @fucm @ThyMYthOS
|
||||||
|
/tests/components/stiebel_eltron/ @fucm @ThyMYthOS
|
||||||
/homeassistant/components/stookwijzer/ @fwestenberg
|
/homeassistant/components/stookwijzer/ @fwestenberg
|
||||||
/tests/components/stookwijzer/ @fwestenberg
|
/tests/components/stookwijzer/ @fwestenberg
|
||||||
/homeassistant/components/stream/ @hunterjm @uvjustin @allenporter
|
/homeassistant/components/stream/ @hunterjm @uvjustin @allenporter
|
||||||
@ -1481,8 +1500,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/subaru/ @G-Two
|
/tests/components/subaru/ @G-Two
|
||||||
/homeassistant/components/suez_water/ @ooii @jb101010-2
|
/homeassistant/components/suez_water/ @ooii @jb101010-2
|
||||||
/tests/components/suez_water/ @ooii @jb101010-2
|
/tests/components/suez_water/ @ooii @jb101010-2
|
||||||
/homeassistant/components/sun/ @Swamp-Ig
|
/homeassistant/components/sun/ @home-assistant/core
|
||||||
/tests/components/sun/ @Swamp-Ig
|
/tests/components/sun/ @home-assistant/core
|
||||||
/homeassistant/components/supla/ @mwegrzynek
|
/homeassistant/components/supla/ @mwegrzynek
|
||||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||||
@ -1495,8 +1514,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/switch_as_x/ @home-assistant/core
|
/tests/components/switch_as_x/ @home-assistant/core
|
||||||
/homeassistant/components/switchbee/ @jafar-atili
|
/homeassistant/components/switchbee/ @jafar-atili
|
||||||
/tests/components/switchbee/ @jafar-atili
|
/tests/components/switchbee/ @jafar-atili
|
||||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
|
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
|
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||||
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
|
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
|
||||||
@ -1534,10 +1553,12 @@ 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 @PhracturedBlue @home-assistant/core
|
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
||||||
/tests/components/template/ @Petro31 @PhracturedBlue @home-assistant/core
|
/tests/components/template/ @Petro31 @home-assistant/core
|
||||||
/homeassistant/components/tesla_fleet/ @Bre77
|
/homeassistant/components/tesla_fleet/ @Bre77
|
||||||
/tests/components/tesla_fleet/ @Bre77
|
/tests/components/tesla_fleet/ @Bre77
|
||||||
/homeassistant/components/tesla_wall_connector/ @einarhauks
|
/homeassistant/components/tesla_wall_connector/ @einarhauks
|
||||||
@ -1563,6 +1584,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/tile/ @bachya
|
/tests/components/tile/ @bachya
|
||||||
/homeassistant/components/tilt_ble/ @apt-itude
|
/homeassistant/components/tilt_ble/ @apt-itude
|
||||||
/tests/components/tilt_ble/ @apt-itude
|
/tests/components/tilt_ble/ @apt-itude
|
||||||
|
/homeassistant/components/tilt_pi/ @michaelheyman
|
||||||
|
/tests/components/tilt_pi/ @michaelheyman
|
||||||
/homeassistant/components/time/ @home-assistant/core
|
/homeassistant/components/time/ @home-assistant/core
|
||||||
/tests/components/time/ @home-assistant/core
|
/tests/components/time/ @home-assistant/core
|
||||||
/homeassistant/components/time_date/ @fabaff
|
/homeassistant/components/time_date/ @fabaff
|
||||||
@ -1635,6 +1658,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
|
||||||
@ -1651,6 +1676,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
|
/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
|
||||||
/homeassistant/components/valve/ @home-assistant/core
|
/homeassistant/components/valve/ @home-assistant/core
|
||||||
/tests/components/valve/ @home-assistant/core
|
/tests/components/valve/ @home-assistant/core
|
||||||
|
/homeassistant/components/vegehub/ @ghowevege
|
||||||
|
/tests/components/vegehub/ @ghowevege
|
||||||
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
||||||
/tests/components/velbus/ @Cereal2nd @brefra
|
/tests/components/velbus/ @Cereal2nd @brefra
|
||||||
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
|
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
|
||||||
@ -1673,8 +1700,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare
|
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare
|
||||||
/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74
|
/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74
|
||||||
/tests/components/vodafone_station/ @paoloantinori @chemelli74
|
/tests/components/vodafone_station/ @paoloantinori @chemelli74
|
||||||
/homeassistant/components/voip/ @balloob @synesthesiam
|
/homeassistant/components/voip/ @balloob @synesthesiam @jaminh
|
||||||
/tests/components/voip/ @balloob @synesthesiam
|
/tests/components/voip/ @balloob @synesthesiam @jaminh
|
||||||
/homeassistant/components/volumio/ @OnFreund
|
/homeassistant/components/volumio/ @OnFreund
|
||||||
/tests/components/volumio/ @OnFreund
|
/tests/components/volumio/ @OnFreund
|
||||||
/homeassistant/components/volvooncall/ @molobrakos
|
/homeassistant/components/volvooncall/ @molobrakos
|
||||||
@ -1731,8 +1758,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
|
||||||
@ -1791,6 +1818,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/zeversolar/ @kvanzuijlen
|
/tests/components/zeversolar/ @kvanzuijlen
|
||||||
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
|
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
|
||||||
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
|
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
|
||||||
|
/homeassistant/components/zimi/ @markhannon
|
||||||
|
/tests/components/zimi/ @markhannon
|
||||||
/homeassistant/components/zodiac/ @JulienTant
|
/homeassistant/components/zodiac/ @JulienTant
|
||||||
/tests/components/zodiac/ @JulienTant
|
/tests/components/zodiac/ @JulienTant
|
||||||
/homeassistant/components/zone/ @home-assistant/core
|
/homeassistant/components/zone/ @home-assistant/core
|
||||||
|
2
Dockerfile
generated
2
Dockerfile
generated
@ -31,7 +31,7 @@ RUN \
|
|||||||
&& go2rtc --version
|
&& go2rtc --version
|
||||||
|
|
||||||
# Install uv
|
# Install uv
|
||||||
RUN pip3 install uv==0.6.10
|
RUN pip3 install uv==0.7.1
|
||||||
|
|
||||||
WORKDIR /usr/src
|
WORKDIR /usr/src
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
10
build.yaml
10
build.yaml
@ -1,10 +1,10 @@
|
|||||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||||
build_from:
|
build_from:
|
||||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.02.1
|
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.05.0
|
||||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.02.1
|
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0
|
||||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.02.1
|
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0
|
||||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.02.1
|
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0
|
||||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.02.1
|
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.0
|
||||||
codenotary:
|
codenotary:
|
||||||
signer: notary@home-assistant.io
|
signer: notary@home-assistant.io
|
||||||
base_image: notary@home-assistant.io
|
base_image: notary@home-assistant.io
|
||||||
|
@ -38,8 +38,7 @@ def validate_python() -> None:
|
|||||||
|
|
||||||
def ensure_config_path(config_dir: str) -> None:
|
def ensure_config_path(config_dir: str) -> None:
|
||||||
"""Validate the configuration directory."""
|
"""Validate the configuration directory."""
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from . import config as config_util # noqa: PLC0415
|
||||||
from . import config as config_util
|
|
||||||
|
|
||||||
lib_dir = os.path.join(config_dir, "deps")
|
lib_dir = os.path.join(config_dir, "deps")
|
||||||
|
|
||||||
@ -80,8 +79,7 @@ def ensure_config_path(config_dir: str) -> None:
|
|||||||
|
|
||||||
def get_arguments() -> argparse.Namespace:
|
def get_arguments() -> argparse.Namespace:
|
||||||
"""Get parsed passed in arguments."""
|
"""Get parsed passed in arguments."""
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from . import config as config_util # noqa: PLC0415
|
||||||
from . import config as config_util
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Home Assistant: Observe, Control, Automate.",
|
description="Home Assistant: Observe, Control, Automate.",
|
||||||
@ -177,8 +175,7 @@ def main() -> int:
|
|||||||
validate_os()
|
validate_os()
|
||||||
|
|
||||||
if args.script is not None:
|
if args.script is not None:
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from . import scripts # noqa: PLC0415
|
||||||
from . import scripts
|
|
||||||
|
|
||||||
return scripts.run(args.script)
|
return scripts.run(args.script)
|
||||||
|
|
||||||
@ -188,8 +185,7 @@ def main() -> int:
|
|||||||
|
|
||||||
ensure_config_path(config_dir)
|
ensure_config_path(config_dir)
|
||||||
|
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from . import config, runner # noqa: PLC0415
|
||||||
from . import config, runner
|
|
||||||
|
|
||||||
safe_mode = config.safe_mode_enabled(config_dir)
|
safe_mode = config.safe_mode_enabled(config_dir)
|
||||||
|
|
||||||
|
@ -52,28 +52,28 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def _generate_secret() -> str:
|
def _generate_secret() -> str:
|
||||||
"""Generate a secret."""
|
"""Generate a secret."""
|
||||||
import pyotp # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
return str(pyotp.random_base32())
|
return str(pyotp.random_base32())
|
||||||
|
|
||||||
|
|
||||||
def _generate_random() -> int:
|
def _generate_random() -> int:
|
||||||
"""Generate a 32 digit number."""
|
"""Generate a 32 digit number."""
|
||||||
import pyotp # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
|
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
|
||||||
|
|
||||||
|
|
||||||
def _generate_otp(secret: str, count: int) -> str:
|
def _generate_otp(secret: str, count: int) -> str:
|
||||||
"""Generate one time password."""
|
"""Generate one time password."""
|
||||||
import pyotp # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
return str(pyotp.HOTP(secret).at(count))
|
return str(pyotp.HOTP(secret).at(count))
|
||||||
|
|
||||||
|
|
||||||
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
||||||
"""Verify one time password."""
|
"""Verify one time password."""
|
||||||
import pyotp # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
return bool(pyotp.HOTP(secret).verify(otp, count))
|
return bool(pyotp.HOTP(secret).verify(otp, count))
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ DUMMY_SECRET = "FPPTH34D4E3MI2HG"
|
|||||||
|
|
||||||
def _generate_qr_code(data: str) -> str:
|
def _generate_qr_code(data: str) -> str:
|
||||||
"""Generate a base64 PNG string represent QR Code image of data."""
|
"""Generate a base64 PNG string represent QR Code image of data."""
|
||||||
import pyqrcode # pylint: disable=import-outside-toplevel
|
import pyqrcode # noqa: PLC0415
|
||||||
|
|
||||||
qr_code = pyqrcode.create(data)
|
qr_code = pyqrcode.create(data)
|
||||||
|
|
||||||
@ -59,7 +59,7 @@ def _generate_qr_code(data: str) -> str:
|
|||||||
|
|
||||||
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
|
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
|
||||||
"""Generate a secret, url, and QR code."""
|
"""Generate a secret, url, and QR code."""
|
||||||
import pyotp # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
ota_secret = pyotp.random_base32()
|
ota_secret = pyotp.random_base32()
|
||||||
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
||||||
@ -107,7 +107,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
|||||||
|
|
||||||
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
|
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
|
||||||
"""Create a ota_secret for user."""
|
"""Create a ota_secret for user."""
|
||||||
import pyotp # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
ota_secret: str = secret or pyotp.random_base32()
|
ota_secret: str = secret or pyotp.random_base32()
|
||||||
|
|
||||||
@ -163,7 +163,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
|||||||
|
|
||||||
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
||||||
"""Validate two factor authentication code."""
|
"""Validate two factor authentication code."""
|
||||||
import pyotp # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
|
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
|
||||||
# even we cannot find user, we still do verify
|
# even we cannot find user, we still do verify
|
||||||
@ -196,7 +196,7 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
|
|||||||
Return self.async_show_form(step_id='init') if user_input is None.
|
Return self.async_show_form(step_id='init') if user_input is None.
|
||||||
Return self.async_create_entry(data={'result': result}) if finish.
|
Return self.async_create_entry(data={'result': result}) if finish.
|
||||||
"""
|
"""
|
||||||
import pyotp # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
@ -1,29 +0,0 @@
|
|||||||
"""Enum backports from standard lib.
|
|
||||||
|
|
||||||
This file contained the backport of the StrEnum of Python 3.11.
|
|
||||||
|
|
||||||
Since we have dropped support for Python 3.10, we can remove this backport.
|
|
||||||
This file is kept for now to avoid breaking custom components that might
|
|
||||||
import it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import StrEnum as _StrEnum
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
from homeassistant.helpers.deprecation import (
|
|
||||||
DeprecatedAlias,
|
|
||||||
all_with_deprecated_constants,
|
|
||||||
check_if_deprecated_constant,
|
|
||||||
dir_with_deprecated_constants,
|
|
||||||
)
|
|
||||||
|
|
||||||
# StrEnum deprecated as of 2024.5 use enum.StrEnum instead.
|
|
||||||
_DEPRECATED_StrEnum = DeprecatedAlias(_StrEnum, "enum.StrEnum", "2025.5")
|
|
||||||
|
|
||||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
|
||||||
__dir__ = partial(
|
|
||||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
|
||||||
)
|
|
||||||
__all__ = all_with_deprecated_constants(globals())
|
|
@ -1,31 +0,0 @@
|
|||||||
"""Functools backports from standard lib.
|
|
||||||
|
|
||||||
This file contained the backport of the cached_property implementation of Python 3.12.
|
|
||||||
|
|
||||||
Since we have dropped support for Python 3.11, we can remove this backport.
|
|
||||||
This file is kept for now to avoid breaking custom components that might
|
|
||||||
import it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# pylint: disable-next=hass-deprecated-import
|
|
||||||
from functools import cached_property as _cached_property, partial
|
|
||||||
|
|
||||||
from homeassistant.helpers.deprecation import (
|
|
||||||
DeprecatedAlias,
|
|
||||||
all_with_deprecated_constants,
|
|
||||||
check_if_deprecated_constant,
|
|
||||||
dir_with_deprecated_constants,
|
|
||||||
)
|
|
||||||
|
|
||||||
# cached_property deprecated as of 2024.5 use functools.cached_property instead.
|
|
||||||
_DEPRECATED_cached_property = DeprecatedAlias(
|
|
||||||
_cached_property, "functools.cached_property", "2025.5"
|
|
||||||
)
|
|
||||||
|
|
||||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
|
||||||
__dir__ = partial(
|
|
||||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
|
||||||
)
|
|
||||||
__all__ = all_with_deprecated_constants(globals())
|
|
@ -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
|
||||||
@ -171,8 +172,6 @@ FRONTEND_INTEGRATIONS = {
|
|||||||
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
|
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
|
||||||
# The substage containing recorder should have no timeout, as it could cancel a database migration.
|
# The substage containing recorder should have no timeout, as it could cancel a database migration.
|
||||||
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
|
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
|
||||||
# The substages preceding it should also have no timeout, until we ensure that the recorder
|
|
||||||
# is not accidentally promoted as a dependency of any of the integrations in them.
|
|
||||||
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
|
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
|
||||||
STAGE_0_INTEGRATIONS = (
|
STAGE_0_INTEGRATIONS = (
|
||||||
# Load logging and http deps as soon as possible
|
# Load logging and http deps as soon as possible
|
||||||
@ -333,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
|
||||||
)
|
)
|
||||||
@ -385,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)
|
||||||
@ -396,7 +396,7 @@ async def async_setup_hass(
|
|||||||
|
|
||||||
def open_hass_ui(hass: core.HomeAssistant) -> None:
|
def open_hass_ui(hass: core.HomeAssistant) -> None:
|
||||||
"""Open the UI."""
|
"""Open the UI."""
|
||||||
import webbrowser # pylint: disable=import-outside-toplevel
|
import webbrowser # noqa: PLC0415
|
||||||
|
|
||||||
if hass.config.api is None or "frontend" not in hass.config.components:
|
if hass.config.api is None or "frontend" not in hass.config.components:
|
||||||
_LOGGER.warning("Cannot launch the UI because frontend not loaded")
|
_LOGGER.warning("Cannot launch the UI because frontend not loaded")
|
||||||
@ -454,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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -563,8 +565,7 @@ async def async_enable_logging(
|
|||||||
|
|
||||||
if not log_no_color:
|
if not log_no_color:
|
||||||
try:
|
try:
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from colorlog import ColoredFormatter # noqa: PLC0415
|
||||||
from colorlog import ColoredFormatter
|
|
||||||
|
|
||||||
# basicConfig must be called after importing colorlog in order to
|
# basicConfig must be called after importing colorlog in order to
|
||||||
# ensure that the handlers it sets up wraps the correct streams.
|
# ensure that the handlers it sets up wraps the correct streams.
|
||||||
@ -870,9 +871,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)
|
||||||
@ -881,10 +882,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)
|
||||||
@ -917,19 +914,24 @@ 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:
|
||||||
await _async_setup_multi_components(hass, stage_all_domains, config)
|
await _async_setup_multi_components(hass, stage_all_domains, config)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
|
async with hass.timeout.async_timeout(
|
||||||
|
timeout,
|
||||||
|
cool_down=COOLDOWN_TIME,
|
||||||
|
cancel_message=f"Bootstrap stage {name} timeout",
|
||||||
|
):
|
||||||
await _async_setup_multi_components(hass, stage_all_domains, config)
|
await _async_setup_multi_components(hass, stage_all_domains, config)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
@ -941,7 +943,11 @@ async def _async_set_up_integrations(
|
|||||||
# Wrap up startup
|
# Wrap up startup
|
||||||
_LOGGER.debug("Waiting for startup to wrap up")
|
_LOGGER.debug("Waiting for startup to wrap up")
|
||||||
try:
|
try:
|
||||||
async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME):
|
async with hass.timeout.async_timeout(
|
||||||
|
WRAP_UP_TIMEOUT,
|
||||||
|
cool_down=COOLDOWN_TIME,
|
||||||
|
cancel_message="Bootstrap startup wrap up timeout",
|
||||||
|
):
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
|
@ -1,5 +1,13 @@
|
|||||||
{
|
{
|
||||||
"domain": "amazon",
|
"domain": "amazon",
|
||||||
"name": "Amazon",
|
"name": "Amazon",
|
||||||
"integrations": ["alexa", "amazon_polly", "aws", "fire_tv", "route53"]
|
"integrations": [
|
||||||
|
"alexa",
|
||||||
|
"alexa_devices",
|
||||||
|
"amazon_polly",
|
||||||
|
"aws",
|
||||||
|
"aws_s3",
|
||||||
|
"fire_tv",
|
||||||
|
"route53"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
"google_assistant_sdk",
|
"google_assistant_sdk",
|
||||||
"google_cloud",
|
"google_cloud",
|
||||||
"google_drive",
|
"google_drive",
|
||||||
|
"google_gemini",
|
||||||
"google_generative_ai_conversation",
|
"google_generative_ai_conversation",
|
||||||
"google_mail",
|
"google_mail",
|
||||||
"google_maps",
|
"google_maps",
|
||||||
|
6
homeassistant/brands/nuki.json
Normal file
6
homeassistant/brands/nuki.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"domain": "nuki",
|
||||||
|
"name": "Nuki",
|
||||||
|
"integrations": ["nuki"],
|
||||||
|
"iot_standards": ["matter"]
|
||||||
|
}
|
6
homeassistant/brands/shelly.json
Normal file
6
homeassistant/brands/shelly.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"domain": "shelly",
|
||||||
|
"name": "shelly",
|
||||||
|
"integrations": ["shelly"],
|
||||||
|
"iot_standards": ["zwave"]
|
||||||
|
}
|
@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
"domain": "sony",
|
"domain": "sony",
|
||||||
"name": "Sony",
|
"name": "Sony",
|
||||||
"integrations": ["braviatv", "ps4", "sony_projector", "songpal"]
|
"integrations": [
|
||||||
|
"braviatv",
|
||||||
|
"ps4",
|
||||||
|
"sony_projector",
|
||||||
|
"songpal",
|
||||||
|
"playstation_network"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"domain": "switchbot",
|
"domain": "switchbot",
|
||||||
"name": "SwitchBot",
|
"name": "SwitchBot",
|
||||||
"integrations": ["switchbot", "switchbot_cloud"]
|
"integrations": ["switchbot", "switchbot_cloud"],
|
||||||
|
"iot_standards": ["matter"]
|
||||||
}
|
}
|
||||||
|
5
homeassistant/brands/tilt.json
Normal file
5
homeassistant/brands/tilt.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"domain": "tilt",
|
||||||
|
"name": "Tilt",
|
||||||
|
"integrations": ["tilt_ble", "tilt_pi"]
|
||||||
|
}
|
@ -14,30 +14,24 @@ from jaraco.abode.exceptions import (
|
|||||||
)
|
)
|
||||||
from jaraco.abode.helpers.timeline import Groups as GROUPS
|
from jaraco.abode.helpers.timeline import Groups as GROUPS
|
||||||
from requests.exceptions import ConnectTimeout, HTTPError
|
from requests.exceptions import ConnectTimeout, HTTPError
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_DATE,
|
ATTR_DATE,
|
||||||
ATTR_DEVICE_ID,
|
ATTR_DEVICE_ID,
|
||||||
ATTR_ENTITY_ID,
|
|
||||||
ATTR_TIME,
|
ATTR_TIME,
|
||||||
CONF_PASSWORD,
|
CONF_PASSWORD,
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, ServiceCall
|
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import CONF_POLLING, DOMAIN, LOGGER
|
from .const import CONF_POLLING, DOMAIN, LOGGER
|
||||||
|
from .services import async_setup_services
|
||||||
SERVICE_SETTINGS = "change_setting"
|
|
||||||
SERVICE_CAPTURE_IMAGE = "capture_image"
|
|
||||||
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
|
|
||||||
|
|
||||||
ATTR_DEVICE_NAME = "device_name"
|
ATTR_DEVICE_NAME = "device_name"
|
||||||
ATTR_DEVICE_TYPE = "device_type"
|
ATTR_DEVICE_TYPE = "device_type"
|
||||||
@ -45,22 +39,12 @@ ATTR_EVENT_CODE = "event_code"
|
|||||||
ATTR_EVENT_NAME = "event_name"
|
ATTR_EVENT_NAME = "event_name"
|
||||||
ATTR_EVENT_TYPE = "event_type"
|
ATTR_EVENT_TYPE = "event_type"
|
||||||
ATTR_EVENT_UTC = "event_utc"
|
ATTR_EVENT_UTC = "event_utc"
|
||||||
ATTR_SETTING = "setting"
|
|
||||||
ATTR_USER_NAME = "user_name"
|
ATTR_USER_NAME = "user_name"
|
||||||
ATTR_APP_TYPE = "app_type"
|
ATTR_APP_TYPE = "app_type"
|
||||||
ATTR_EVENT_BY = "event_by"
|
ATTR_EVENT_BY = "event_by"
|
||||||
ATTR_VALUE = "value"
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||||
|
|
||||||
CHANGE_SETTING_SCHEMA = vol.Schema(
|
|
||||||
{vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string}
|
|
||||||
)
|
|
||||||
|
|
||||||
CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
|
||||||
|
|
||||||
AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
|
||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.ALARM_CONTROL_PANEL,
|
Platform.ALARM_CONTROL_PANEL,
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
@ -85,7 +69,7 @@ class AbodeSystem:
|
|||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the Abode component."""
|
"""Set up the Abode component."""
|
||||||
setup_hass_services(hass)
|
async_setup_services(hass)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@ -138,60 +122,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
def setup_hass_services(hass: HomeAssistant) -> None:
|
|
||||||
"""Home Assistant services."""
|
|
||||||
|
|
||||||
def change_setting(call: ServiceCall) -> None:
|
|
||||||
"""Change an Abode system setting."""
|
|
||||||
setting = call.data[ATTR_SETTING]
|
|
||||||
value = call.data[ATTR_VALUE]
|
|
||||||
|
|
||||||
try:
|
|
||||||
hass.data[DOMAIN].abode.set_setting(setting, value)
|
|
||||||
except AbodeException as ex:
|
|
||||||
LOGGER.warning(ex)
|
|
||||||
|
|
||||||
def capture_image(call: ServiceCall) -> None:
|
|
||||||
"""Capture a new image."""
|
|
||||||
entity_ids = call.data[ATTR_ENTITY_ID]
|
|
||||||
|
|
||||||
target_entities = [
|
|
||||||
entity_id
|
|
||||||
for entity_id in hass.data[DOMAIN].entity_ids
|
|
||||||
if entity_id in entity_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
for entity_id in target_entities:
|
|
||||||
signal = f"abode_camera_capture_{entity_id}"
|
|
||||||
dispatcher_send(hass, signal)
|
|
||||||
|
|
||||||
def trigger_automation(call: ServiceCall) -> None:
|
|
||||||
"""Trigger an Abode automation."""
|
|
||||||
entity_ids = call.data[ATTR_ENTITY_ID]
|
|
||||||
|
|
||||||
target_entities = [
|
|
||||||
entity_id
|
|
||||||
for entity_id in hass.data[DOMAIN].entity_ids
|
|
||||||
if entity_id in entity_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
for entity_id in target_entities:
|
|
||||||
signal = f"abode_trigger_automation_{entity_id}"
|
|
||||||
dispatcher_send(hass, signal)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN, SERVICE_SETTINGS, change_setting, schema=CHANGE_SETTING_SCHEMA
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, schema=CAPTURE_IMAGE_SCHEMA
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN, SERVICE_TRIGGER_AUTOMATION, trigger_automation, schema=AUTOMATION_SCHEMA
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def setup_hass_events(hass: HomeAssistant) -> None:
|
async def setup_hass_events(hass: HomeAssistant) -> None:
|
||||||
"""Home Assistant start and stop callbacks."""
|
"""Home Assistant start and stop callbacks."""
|
||||||
|
|
||||||
|
90
homeassistant/components/abode/services.py
Normal file
90
homeassistant/components/abode/services.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""Support for the Abode Security System."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from jaraco.abode.exceptions import Exception as AbodeException
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||||
|
|
||||||
|
from .const import DOMAIN, LOGGER
|
||||||
|
|
||||||
|
SERVICE_SETTINGS = "change_setting"
|
||||||
|
SERVICE_CAPTURE_IMAGE = "capture_image"
|
||||||
|
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
|
||||||
|
|
||||||
|
ATTR_SETTING = "setting"
|
||||||
|
ATTR_VALUE = "value"
|
||||||
|
|
||||||
|
|
||||||
|
CHANGE_SETTING_SCHEMA = vol.Schema(
|
||||||
|
{vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string}
|
||||||
|
)
|
||||||
|
|
||||||
|
CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
||||||
|
|
||||||
|
AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
||||||
|
|
||||||
|
|
||||||
|
def _change_setting(call: ServiceCall) -> None:
|
||||||
|
"""Change an Abode system setting."""
|
||||||
|
setting = call.data[ATTR_SETTING]
|
||||||
|
value = call.data[ATTR_VALUE]
|
||||||
|
|
||||||
|
try:
|
||||||
|
call.hass.data[DOMAIN].abode.set_setting(setting, value)
|
||||||
|
except AbodeException as ex:
|
||||||
|
LOGGER.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_image(call: ServiceCall) -> None:
|
||||||
|
"""Capture a new image."""
|
||||||
|
entity_ids = call.data[ATTR_ENTITY_ID]
|
||||||
|
|
||||||
|
target_entities = [
|
||||||
|
entity_id
|
||||||
|
for entity_id in call.hass.data[DOMAIN].entity_ids
|
||||||
|
if entity_id in entity_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
for entity_id in target_entities:
|
||||||
|
signal = f"abode_camera_capture_{entity_id}"
|
||||||
|
dispatcher_send(call.hass, signal)
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_automation(call: ServiceCall) -> None:
|
||||||
|
"""Trigger an Abode automation."""
|
||||||
|
entity_ids = call.data[ATTR_ENTITY_ID]
|
||||||
|
|
||||||
|
target_entities = [
|
||||||
|
entity_id
|
||||||
|
for entity_id in call.hass.data[DOMAIN].entity_ids
|
||||||
|
if entity_id in entity_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
for entity_id in target_entities:
|
||||||
|
signal = f"abode_trigger_automation_{entity_id}"
|
||||||
|
dispatcher_send(call.hass, signal)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Home Assistant services."""
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_SETTINGS, _change_setting, schema=CHANGE_SETTING_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_CAPTURE_IMAGE, _capture_image, schema=CAPTURE_IMAGE_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_TRIGGER_AUTOMATION,
|
||||||
|
_trigger_automation,
|
||||||
|
schema=AUTOMATION_SCHEMA,
|
||||||
|
)
|
@ -67,6 +67,7 @@ POLLEN_CATEGORY_MAP = {
|
|||||||
2: "moderate",
|
2: "moderate",
|
||||||
3: "high",
|
3: "high",
|
||||||
4: "very_high",
|
4: "very_high",
|
||||||
|
5: "extreme",
|
||||||
}
|
}
|
||||||
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40)
|
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40)
|
||||||
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
|
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
|
||||||
|
@ -72,6 +72,7 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "Level",
|
"name": "Level",
|
||||||
"state": {
|
"state": {
|
||||||
|
"extreme": "Extreme",
|
||||||
"high": "[%key:common::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:common::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "Moderate",
|
"moderate": "Moderate",
|
||||||
@ -89,6 +90,7 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
|
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
|
||||||
"high": "[%key:common::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:common::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||||
@ -123,6 +125,7 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
|
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
|
||||||
"high": "[%key:common::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:common::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||||
@ -167,6 +170,7 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
|
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
|
||||||
"high": "[%key:common::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:common::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||||
@ -181,6 +185,7 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
|
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
|
||||||
"high": "[%key:common::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:common::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||||
@ -195,6 +200,7 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
|
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
|
||||||
"high": "[%key:common::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:common::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||||
|
@ -40,9 +40,10 @@ class AcmedaFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
entry.unique_id for entry in self._async_current_entries()
|
entry.unique_id for entry in self._async_current_entries()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hubs: list[aiopulse.Hub] = []
|
||||||
with suppress(TimeoutError):
|
with suppress(TimeoutError):
|
||||||
async with timeout(5):
|
async with timeout(5):
|
||||||
hubs: list[aiopulse.Hub] = [
|
hubs = [
|
||||||
hub
|
hub
|
||||||
async for hub in aiopulse.Hub.discover()
|
async for hub in aiopulse.Hub.discover()
|
||||||
if hub.id not in already_configured
|
if hub.id not in already_configured
|
||||||
|
@ -2,25 +2,38 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
PLATFORMS = [Platform.CLIMATE]
|
from .const import CONNECTION_TYPE, LOCAL
|
||||||
|
from .coordinator import AdaxCloudCoordinator, AdaxConfigEntry, AdaxLocalCoordinator
|
||||||
|
|
||||||
|
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AdaxConfigEntry) -> bool:
|
||||||
"""Set up Adax from a config entry."""
|
"""Set up Adax from a config entry."""
|
||||||
|
if entry.data.get(CONNECTION_TYPE) == LOCAL:
|
||||||
|
local_coordinator = AdaxLocalCoordinator(hass, entry)
|
||||||
|
entry.runtime_data = local_coordinator
|
||||||
|
else:
|
||||||
|
cloud_coordinator = AdaxCloudCoordinator(hass, entry)
|
||||||
|
entry.runtime_data = cloud_coordinator
|
||||||
|
|
||||||
|
await entry.runtime_data.async_config_entry_first_refresh()
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: AdaxConfigEntry) -> 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 async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
async def async_migrate_entry(
|
||||||
|
hass: HomeAssistant, config_entry: AdaxConfigEntry
|
||||||
|
) -> bool:
|
||||||
"""Migrate old entry."""
|
"""Migrate old entry."""
|
||||||
# convert title and unique_id to string
|
# convert title and unique_id to string
|
||||||
if config_entry.version == 1:
|
if config_entry.version == 1:
|
||||||
|
@ -12,57 +12,42 @@ from homeassistant.components.climate import (
|
|||||||
ClimateEntityFeature,
|
ClimateEntityFeature,
|
||||||
HVACMode,
|
HVACMode,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_TEMPERATURE,
|
ATTR_TEMPERATURE,
|
||||||
CONF_IP_ADDRESS,
|
|
||||||
CONF_PASSWORD,
|
|
||||||
CONF_TOKEN,
|
|
||||||
CONF_UNIQUE_ID,
|
CONF_UNIQUE_ID,
|
||||||
PRECISION_WHOLE,
|
PRECISION_WHOLE,
|
||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL
|
from . import AdaxConfigEntry
|
||||||
|
from .const import CONNECTION_TYPE, DOMAIN, LOCAL
|
||||||
|
from .coordinator import AdaxCloudCoordinator, AdaxLocalCoordinator
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: AdaxConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Adax thermostat with config flow."""
|
"""Set up the Adax thermostat with config flow."""
|
||||||
if entry.data.get(CONNECTION_TYPE) == LOCAL:
|
if entry.data.get(CONNECTION_TYPE) == LOCAL:
|
||||||
adax_data_handler = AdaxLocal(
|
local_coordinator = cast(AdaxLocalCoordinator, entry.runtime_data)
|
||||||
entry.data[CONF_IP_ADDRESS],
|
|
||||||
entry.data[CONF_TOKEN],
|
|
||||||
websession=async_get_clientsession(hass, verify_ssl=False),
|
|
||||||
)
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[LocalAdaxDevice(adax_data_handler, entry.data[CONF_UNIQUE_ID])], True
|
[LocalAdaxDevice(local_coordinator, entry.data[CONF_UNIQUE_ID])],
|
||||||
)
|
)
|
||||||
return
|
else:
|
||||||
|
cloud_coordinator = cast(AdaxCloudCoordinator, entry.runtime_data)
|
||||||
adax_data_handler = Adax(
|
|
||||||
entry.data[ACCOUNT_ID],
|
|
||||||
entry.data[CONF_PASSWORD],
|
|
||||||
websession=async_get_clientsession(hass),
|
|
||||||
)
|
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
(
|
AdaxDevice(cloud_coordinator, device_id)
|
||||||
AdaxDevice(room, adax_data_handler)
|
for device_id in cloud_coordinator.data
|
||||||
for room in await adax_data_handler.get_rooms()
|
|
||||||
),
|
|
||||||
True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AdaxDevice(ClimateEntity):
|
class AdaxDevice(CoordinatorEntity[AdaxCloudCoordinator], ClimateEntity):
|
||||||
"""Representation of a heater."""
|
"""Representation of a heater."""
|
||||||
|
|
||||||
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
|
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
|
||||||
@ -76,20 +61,37 @@ class AdaxDevice(ClimateEntity):
|
|||||||
_attr_target_temperature_step = PRECISION_WHOLE
|
_attr_target_temperature_step = PRECISION_WHOLE
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
|
|
||||||
def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: AdaxCloudCoordinator,
|
||||||
|
device_id: str,
|
||||||
|
) -> None:
|
||||||
"""Initialize the heater."""
|
"""Initialize the heater."""
|
||||||
self._device_id = heater_data["id"]
|
super().__init__(coordinator)
|
||||||
self._adax_data_handler = adax_data_handler
|
self._adax_data_handler: Adax = coordinator.adax_data_handler
|
||||||
|
self._device_id = device_id
|
||||||
|
|
||||||
self._attr_unique_id = f"{heater_data['homeId']}_{heater_data['id']}"
|
self._attr_name = self.room["name"]
|
||||||
|
self._attr_unique_id = f"{self.room['homeId']}_{self._device_id}"
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, heater_data["id"])},
|
identifiers={(DOMAIN, device_id)},
|
||||||
# Instead of setting the device name to the entity name, adax
|
# Instead of setting the device name to the entity name, adax
|
||||||
# should be updated to set has_entity_name = True, and set the entity
|
# should be updated to set has_entity_name = True, and set the entity
|
||||||
# name to None
|
# name to None
|
||||||
name=cast(str | None, self.name),
|
name=cast(str | None, self.name),
|
||||||
manufacturer="Adax",
|
manufacturer="Adax",
|
||||||
)
|
)
|
||||||
|
self._apply_data(self.room)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Whether the entity is available or not."""
|
||||||
|
return super().available and self._device_id in self.coordinator.data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def room(self) -> dict[str, Any]:
|
||||||
|
"""Gets the data for this particular device."""
|
||||||
|
return self.coordinator.data[self._device_id]
|
||||||
|
|
||||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||||
"""Set hvac mode."""
|
"""Set hvac mode."""
|
||||||
@ -104,7 +106,9 @@ class AdaxDevice(ClimateEntity):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
await self._adax_data_handler.update()
|
|
||||||
|
# Request data refresh from source to verify that update was successful
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||||
"""Set new target temperature."""
|
"""Set new target temperature."""
|
||||||
@ -114,12 +118,15 @@ class AdaxDevice(ClimateEntity):
|
|||||||
self._device_id, temperature, True
|
self._device_id, temperature, True
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
@callback
|
||||||
"""Get the latest data."""
|
def _handle_coordinator_update(self) -> None:
|
||||||
for room in await self._adax_data_handler.get_rooms():
|
"""Handle updated data from the coordinator."""
|
||||||
if room["id"] != self._device_id:
|
if room := self.room:
|
||||||
continue
|
self._apply_data(room)
|
||||||
self._attr_name = room["name"]
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
|
def _apply_data(self, room: dict[str, Any]) -> None:
|
||||||
|
"""Update the appropriate attributues based on received data."""
|
||||||
self._attr_current_temperature = room.get("temperature")
|
self._attr_current_temperature = room.get("temperature")
|
||||||
self._attr_target_temperature = room.get("targetTemperature")
|
self._attr_target_temperature = room.get("targetTemperature")
|
||||||
if room["heatingEnabled"]:
|
if room["heatingEnabled"]:
|
||||||
@ -128,14 +135,14 @@ class AdaxDevice(ClimateEntity):
|
|||||||
else:
|
else:
|
||||||
self._attr_hvac_mode = HVACMode.OFF
|
self._attr_hvac_mode = HVACMode.OFF
|
||||||
self._attr_icon = "mdi:radiator-off"
|
self._attr_icon = "mdi:radiator-off"
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
class LocalAdaxDevice(ClimateEntity):
|
class LocalAdaxDevice(CoordinatorEntity[AdaxLocalCoordinator], ClimateEntity):
|
||||||
"""Representation of a heater."""
|
"""Representation of a heater."""
|
||||||
|
|
||||||
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
|
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
|
||||||
_attr_hvac_mode = HVACMode.HEAT
|
_attr_hvac_mode = HVACMode.OFF
|
||||||
|
_attr_icon = "mdi:radiator-off"
|
||||||
_attr_max_temp = 35
|
_attr_max_temp = 35
|
||||||
_attr_min_temp = 5
|
_attr_min_temp = 5
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
@ -146,9 +153,10 @@ class LocalAdaxDevice(ClimateEntity):
|
|||||||
_attr_target_temperature_step = PRECISION_WHOLE
|
_attr_target_temperature_step = PRECISION_WHOLE
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
|
|
||||||
def __init__(self, adax_data_handler: AdaxLocal, unique_id: str) -> None:
|
def __init__(self, coordinator: AdaxLocalCoordinator, unique_id: str) -> None:
|
||||||
"""Initialize the heater."""
|
"""Initialize the heater."""
|
||||||
self._adax_data_handler = adax_data_handler
|
super().__init__(coordinator)
|
||||||
|
self._adax_data_handler: AdaxLocal = coordinator.adax_data_handler
|
||||||
self._attr_unique_id = unique_id
|
self._attr_unique_id = unique_id
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, unique_id)},
|
identifiers={(DOMAIN, unique_id)},
|
||||||
@ -169,9 +177,10 @@ class LocalAdaxDevice(ClimateEntity):
|
|||||||
return
|
return
|
||||||
await self._adax_data_handler.set_target_temperature(temperature)
|
await self._adax_data_handler.set_target_temperature(temperature)
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
@callback
|
||||||
"""Get the latest data."""
|
def _handle_coordinator_update(self) -> None:
|
||||||
data = await self._adax_data_handler.get_status()
|
"""Handle updated data from the coordinator."""
|
||||||
|
if data := self.coordinator.data:
|
||||||
self._attr_current_temperature = data["current_temperature"]
|
self._attr_current_temperature = data["current_temperature"]
|
||||||
self._attr_available = self._attr_current_temperature is not None
|
self._attr_available = self._attr_current_temperature is not None
|
||||||
if (target_temp := data["target_temperature"]) == 0:
|
if (target_temp := data["target_temperature"]) == 0:
|
||||||
@ -183,3 +192,5 @@ class LocalAdaxDevice(ClimateEntity):
|
|||||||
self._attr_hvac_mode = HVACMode.HEAT
|
self._attr_hvac_mode = HVACMode.HEAT
|
||||||
self._attr_icon = "mdi:radiator"
|
self._attr_icon = "mdi:radiator"
|
||||||
self._attr_target_temperature = target_temp
|
self._attr_target_temperature = target_temp
|
||||||
|
|
||||||
|
super()._handle_coordinator_update()
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""Constants for the Adax integration."""
|
"""Constants for the Adax integration."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
ACCOUNT_ID: Final = "account_id"
|
ACCOUNT_ID: Final = "account_id"
|
||||||
@ -9,3 +10,5 @@ DOMAIN: Final = "adax"
|
|||||||
LOCAL = "Local"
|
LOCAL = "Local"
|
||||||
WIFI_SSID = "wifi_ssid"
|
WIFI_SSID = "wifi_ssid"
|
||||||
WIFI_PSWD = "wifi_pswd"
|
WIFI_PSWD = "wifi_pswd"
|
||||||
|
|
||||||
|
SCAN_INTERVAL = datetime.timedelta(seconds=60)
|
||||||
|
94
homeassistant/components/adax/coordinator.py
Normal file
94
homeassistant/components/adax/coordinator.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"""DataUpdateCoordinator for the Adax component."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from adax import Adax
|
||||||
|
from adax_local import Adax as AdaxLocal
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_TOKEN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import ACCOUNT_ID, SCAN_INTERVAL
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
type AdaxConfigEntry = ConfigEntry[AdaxCloudCoordinator | AdaxLocalCoordinator]
|
||||||
|
|
||||||
|
|
||||||
|
class AdaxCloudCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
|
||||||
|
"""Coordinator for updating data to and from Adax (cloud)."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: AdaxConfigEntry) -> None:
|
||||||
|
"""Initialize the Adax coordinator used for Cloud mode."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
config_entry=entry,
|
||||||
|
logger=_LOGGER,
|
||||||
|
name="AdaxCloud",
|
||||||
|
update_interval=SCAN_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.adax_data_handler = Adax(
|
||||||
|
entry.data[ACCOUNT_ID],
|
||||||
|
entry.data[CONF_PASSWORD],
|
||||||
|
websession=async_get_clientsession(hass),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Fetch data from the Adax."""
|
||||||
|
try:
|
||||||
|
if hasattr(self.adax_data_handler, "fetch_rooms_info"):
|
||||||
|
rooms = await self.adax_data_handler.fetch_rooms_info() or []
|
||||||
|
_LOGGER.debug("fetch_rooms_info returned: %s", rooms)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("fetch_rooms_info method not available, using get_rooms")
|
||||||
|
rooms = []
|
||||||
|
|
||||||
|
if not rooms:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"No rooms from fetch_rooms_info, trying get_rooms as fallback"
|
||||||
|
)
|
||||||
|
rooms = await self.adax_data_handler.get_rooms() or []
|
||||||
|
_LOGGER.debug("get_rooms fallback returned: %s", rooms)
|
||||||
|
|
||||||
|
if not rooms:
|
||||||
|
raise UpdateFailed("No rooms available from Adax API")
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
raise UpdateFailed(f"Error communicating with API: {e}") from e
|
||||||
|
|
||||||
|
for room in rooms:
|
||||||
|
room["energyWh"] = int(room.get("energyWh", 0))
|
||||||
|
|
||||||
|
return {r["id"]: r for r in rooms}
|
||||||
|
|
||||||
|
|
||||||
|
class AdaxLocalCoordinator(DataUpdateCoordinator[dict[str, Any] | None]):
|
||||||
|
"""Coordinator for updating data to and from Adax (local)."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: AdaxConfigEntry) -> None:
|
||||||
|
"""Initialize the Adax coordinator used for Local mode."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
config_entry=entry,
|
||||||
|
logger=_LOGGER,
|
||||||
|
name="AdaxLocal",
|
||||||
|
update_interval=SCAN_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.adax_data_handler = AdaxLocal(
|
||||||
|
entry.data[CONF_IP_ADDRESS],
|
||||||
|
entry.data[CONF_TOKEN],
|
||||||
|
websession=async_get_clientsession(hass, verify_ssl=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
|
"""Fetch data from the Adax."""
|
||||||
|
if result := await self.adax_data_handler.get_status():
|
||||||
|
return cast(dict[str, Any], result)
|
||||||
|
raise UpdateFailed("Got invalid status from device")
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "adax",
|
"domain": "adax",
|
||||||
"name": "Adax",
|
"name": "Adax",
|
||||||
"codeowners": ["@danielhiversen"],
|
"codeowners": ["@danielhiversen", "@lazytarget"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/adax",
|
"documentation": "https://www.home-assistant.io/integrations/adax",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
77
homeassistant/components/adax/sensor.py
Normal file
77
homeassistant/components/adax/sensor.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""Support for Adax energy sensors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.const import UnitOfEnergy
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from . import AdaxConfigEntry
|
||||||
|
from .const import CONNECTION_TYPE, DOMAIN, LOCAL
|
||||||
|
from .coordinator import AdaxCloudCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AdaxConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Adax energy sensors with config flow."""
|
||||||
|
if entry.data.get(CONNECTION_TYPE) != LOCAL:
|
||||||
|
cloud_coordinator = cast(AdaxCloudCoordinator, entry.runtime_data)
|
||||||
|
|
||||||
|
# Create individual energy sensors for each device
|
||||||
|
async_add_entities(
|
||||||
|
AdaxEnergySensor(cloud_coordinator, device_id)
|
||||||
|
for device_id in cloud_coordinator.data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdaxEnergySensor(CoordinatorEntity[AdaxCloudCoordinator], SensorEntity):
|
||||||
|
"""Representation of an Adax energy sensor."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_translation_key = "energy"
|
||||||
|
_attr_device_class = SensorDeviceClass.ENERGY
|
||||||
|
_attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR
|
||||||
|
_attr_suggested_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
|
||||||
|
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||||
|
_attr_suggested_display_precision = 3
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: AdaxCloudCoordinator,
|
||||||
|
device_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the energy sensor."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._device_id = device_id
|
||||||
|
room = coordinator.data[device_id]
|
||||||
|
|
||||||
|
self._attr_unique_id = f"{room['homeId']}_{device_id}_energy"
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, device_id)},
|
||||||
|
name=room["name"],
|
||||||
|
manufacturer="Adax",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True if entity is available."""
|
||||||
|
return (
|
||||||
|
super().available and "energyWh" in self.coordinator.data[self._device_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> int:
|
||||||
|
"""Return the native value of the sensor."""
|
||||||
|
return int(self.coordinator.data[self._device_id]["energyWh"])
|
@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import AdvantageAirDataConfigEntry
|
from . import AdvantageAirDataConfigEntry
|
||||||
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN
|
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN
|
||||||
from .entity import AdvantageAirEntity, AdvantageAirThingEntity
|
from .entity import AdvantageAirEntity, AdvantageAirThingEntity
|
||||||
from .models import AdvantageAirData
|
from .models import AdvantageAirData
|
||||||
|
|
||||||
@ -52,8 +52,8 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
|
|||||||
self._id: str = light["id"]
|
self._id: str = light["id"]
|
||||||
self._attr_unique_id += f"-{self._id}"
|
self._attr_unique_id += f"-{self._id}"
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(ADVANTAGE_AIR_DOMAIN, self._attr_unique_id)},
|
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||||
via_device=(ADVANTAGE_AIR_DOMAIN, self.coordinator.data["system"]["rid"]),
|
via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
|
||||||
manufacturer="Advantage Air",
|
manufacturer="Advantage Air",
|
||||||
model=light.get("moduleType"),
|
model=light.get("moduleType"),
|
||||||
name=light["name"],
|
name=light["name"],
|
||||||
|
@ -6,7 +6,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import AdvantageAirDataConfigEntry
|
from . import AdvantageAirDataConfigEntry
|
||||||
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN
|
from .const import DOMAIN
|
||||||
from .entity import AdvantageAirEntity
|
from .entity import AdvantageAirEntity
|
||||||
from .models import AdvantageAirData
|
from .models import AdvantageAirData
|
||||||
|
|
||||||
@ -32,9 +32,7 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity):
|
|||||||
"""Initialize the Advantage Air App."""
|
"""Initialize the Advantage Air App."""
|
||||||
super().__init__(instance)
|
super().__init__(instance)
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={
|
identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])},
|
||||||
(ADVANTAGE_AIR_DOMAIN, self.coordinator.data["system"]["rid"])
|
|
||||||
},
|
|
||||||
manufacturer="Advantage Air",
|
manufacturer="Advantage Air",
|
||||||
model=self.coordinator.data["system"]["sysType"],
|
model=self.coordinator.data["system"]["sysType"],
|
||||||
name=self.coordinator.data["system"]["name"],
|
name=self.coordinator.data["system"]["name"],
|
||||||
|
@ -185,6 +185,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
|||||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||||
name="Daily forecast wind bearing",
|
name="Daily forecast wind bearing",
|
||||||
native_unit_of_measurement=DEGREE,
|
native_unit_of_measurement=DEGREE,
|
||||||
|
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||||
),
|
),
|
||||||
AemetSensorEntityDescription(
|
AemetSensorEntityDescription(
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
@ -192,6 +193,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
|||||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||||
name="Hourly forecast wind bearing",
|
name="Hourly forecast wind bearing",
|
||||||
native_unit_of_measurement=DEGREE,
|
native_unit_of_measurement=DEGREE,
|
||||||
|
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||||
),
|
),
|
||||||
AemetSensorEntityDescription(
|
AemetSensorEntityDescription(
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
@ -334,7 +336,8 @@ 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,
|
||||||
),
|
),
|
||||||
AemetSensorEntityDescription(
|
AemetSensorEntityDescription(
|
||||||
key=ATTR_API_WIND_MAX_SPEED,
|
key=ATTR_API_WIND_MAX_SPEED,
|
||||||
|
@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
|||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import DOMAIN as AGENT_DOMAIN, SERVER_URL
|
from .const import DOMAIN, SERVER_URL
|
||||||
|
|
||||||
ATTRIBUTION = "ispyconnect.com"
|
ATTRIBUTION = "ispyconnect.com"
|
||||||
DEFAULT_BRAND = "Agent DVR by ispyconnect.com"
|
DEFAULT_BRAND = "Agent DVR by ispyconnect.com"
|
||||||
@ -46,7 +46,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
device_registry.async_get_or_create(
|
device_registry.async_get_or_create(
|
||||||
config_entry_id=config_entry.entry_id,
|
config_entry_id=config_entry.entry_id,
|
||||||
identifiers={(AGENT_DOMAIN, agent_client.unique)},
|
identifiers={(DOMAIN, agent_client.unique)},
|
||||||
manufacturer="iSpyConnect",
|
manufacturer="iSpyConnect",
|
||||||
name=f"Agent {agent_client.name}",
|
name=f"Agent {agent_client.name}",
|
||||||
model="Agent DVR",
|
model="Agent DVR",
|
||||||
|
@ -12,7 +12,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import AgentDVRConfigEntry
|
from . import AgentDVRConfigEntry
|
||||||
from .const import DOMAIN as AGENT_DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
CONF_HOME_MODE_NAME = "home"
|
CONF_HOME_MODE_NAME = "home"
|
||||||
CONF_AWAY_MODE_NAME = "away"
|
CONF_AWAY_MODE_NAME = "away"
|
||||||
@ -47,7 +47,7 @@ class AgentBaseStation(AlarmControlPanelEntity):
|
|||||||
self._client = client
|
self._client = client
|
||||||
self._attr_unique_id = f"{client.unique}_CP"
|
self._attr_unique_id = f"{client.unique}_CP"
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(AGENT_DOMAIN, client.unique)},
|
identifiers={(DOMAIN, client.unique)},
|
||||||
name=f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}",
|
name=f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}",
|
||||||
manufacturer="Agent",
|
manufacturer="Agent",
|
||||||
model=CONST_ALARM_CONTROL_PANEL_NAME,
|
model=CONST_ALARM_CONTROL_PANEL_NAME,
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from . import AgentDVRConfigEntry
|
from . import AgentDVRConfigEntry
|
||||||
from .const import ATTRIBUTION, CAMERA_SCAN_INTERVAL_SECS, DOMAIN as AGENT_DOMAIN
|
from .const import ATTRIBUTION, CAMERA_SCAN_INTERVAL_SECS, DOMAIN
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=CAMERA_SCAN_INTERVAL_SECS)
|
SCAN_INTERVAL = timedelta(seconds=CAMERA_SCAN_INTERVAL_SECS)
|
||||||
|
|
||||||
@ -82,7 +82,7 @@ class AgentCamera(MjpegCamera):
|
|||||||
still_image_url=f"{device.client._server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", # noqa: SLF001
|
still_image_url=f"{device.client._server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", # noqa: SLF001
|
||||||
)
|
)
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(AGENT_DOMAIN, self.unique_id)},
|
identifiers={(DOMAIN, self.unique_id)},
|
||||||
manufacturer="Agent",
|
manufacturer="Agent",
|
||||||
model="Camera",
|
model="Camera",
|
||||||
name=f"{device.client.name} {device.name}",
|
name=f"{device.client.name} {device.name}",
|
||||||
|
166
homeassistant/components/ai_task/__init__.py
Normal file
166
homeassistant/components/ai_task/__init__.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"""Integration to offer AI tasks to Home Assistant."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR
|
||||||
|
from homeassistant.core import (
|
||||||
|
HassJobType,
|
||||||
|
HomeAssistant,
|
||||||
|
ServiceCall,
|
||||||
|
ServiceResponse,
|
||||||
|
SupportsResponse,
|
||||||
|
callback,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers import config_validation as cv, selector, storage
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTR_ATTACHMENTS,
|
||||||
|
ATTR_INSTRUCTIONS,
|
||||||
|
ATTR_REQUIRED,
|
||||||
|
ATTR_STRUCTURE,
|
||||||
|
ATTR_TASK_NAME,
|
||||||
|
DATA_COMPONENT,
|
||||||
|
DATA_PREFERENCES,
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_GENERATE_DATA,
|
||||||
|
AITaskEntityFeature,
|
||||||
|
)
|
||||||
|
from .entity import AITaskEntity
|
||||||
|
from .http import async_setup as async_setup_http
|
||||||
|
from .task import GenDataTask, GenDataTaskResult, async_generate_data
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DOMAIN",
|
||||||
|
"AITaskEntity",
|
||||||
|
"AITaskEntityFeature",
|
||||||
|
"GenDataTask",
|
||||||
|
"GenDataTaskResult",
|
||||||
|
"async_generate_data",
|
||||||
|
"async_setup",
|
||||||
|
"async_setup_entry",
|
||||||
|
"async_unload_entry",
|
||||||
|
]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Register the process service."""
|
||||||
|
entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass)
|
||||||
|
hass.data[DATA_COMPONENT] = entity_component
|
||||||
|
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
|
||||||
|
await hass.data[DATA_PREFERENCES].async_load()
|
||||||
|
async_setup_http(hass)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_GENERATE_DATA,
|
||||||
|
async_service_generate_data,
|
||||||
|
schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_TASK_NAME): cv.string,
|
||||||
|
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
|
||||||
|
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,
|
||||||
|
job_type=HassJobType.Coroutinefunction,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up a config entry."""
|
||||||
|
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_service_generate_data(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Run the run task service."""
|
||||||
|
result = await async_generate_data(hass=call.hass, **call.data)
|
||||||
|
return result.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
class AITaskPreferences:
|
||||||
|
"""AI Task preferences."""
|
||||||
|
|
||||||
|
KEYS = ("gen_data_entity_id",)
|
||||||
|
|
||||||
|
gen_data_entity_id: str | None = None
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Initialize the preferences."""
|
||||||
|
self._store: storage.Store[dict[str, str | None]] = storage.Store(
|
||||||
|
hass, 1, DOMAIN
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_load(self) -> None:
|
||||||
|
"""Load the data from the store."""
|
||||||
|
data = await self._store.async_load()
|
||||||
|
if data is None:
|
||||||
|
return
|
||||||
|
for key in self.KEYS:
|
||||||
|
setattr(self, key, data[key])
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_set_preferences(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
gen_data_entity_id: str | None | UndefinedType = UNDEFINED,
|
||||||
|
) -> None:
|
||||||
|
"""Set the preferences."""
|
||||||
|
changed = False
|
||||||
|
for key, value in (("gen_data_entity_id", gen_data_entity_id),):
|
||||||
|
if value is not UNDEFINED:
|
||||||
|
if getattr(self, key) != value:
|
||||||
|
setattr(self, key, value)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._store.async_delay_save(self.as_dict, 10)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def as_dict(self) -> dict[str, str | None]:
|
||||||
|
"""Get the current preferences."""
|
||||||
|
return {key: getattr(self, key) for key in self.KEYS}
|
40
homeassistant/components/ai_task/const.py
Normal file
40
homeassistant/components/ai_task/const.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""Constants for the AI Task integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import IntFlag
|
||||||
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
|
||||||
|
from . import AITaskPreferences
|
||||||
|
from .entity import AITaskEntity
|
||||||
|
|
||||||
|
DOMAIN = "ai_task"
|
||||||
|
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
|
||||||
|
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
|
||||||
|
|
||||||
|
SERVICE_GENERATE_DATA = "generate_data"
|
||||||
|
|
||||||
|
ATTR_INSTRUCTIONS: Final = "instructions"
|
||||||
|
ATTR_TASK_NAME: Final = "task_name"
|
||||||
|
ATTR_STRUCTURE: Final = "structure"
|
||||||
|
ATTR_REQUIRED: Final = "required"
|
||||||
|
ATTR_ATTACHMENTS: Final = "attachments"
|
||||||
|
|
||||||
|
DEFAULT_SYSTEM_PROMPT = (
|
||||||
|
"You are a Home Assistant expert and help users with their tasks."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AITaskEntityFeature(IntFlag):
|
||||||
|
"""Supported features of the AI task entity."""
|
||||||
|
|
||||||
|
GENERATE_DATA = 1
|
||||||
|
"""Generate data based on instructions."""
|
||||||
|
|
||||||
|
SUPPORT_ATTACHMENTS = 2
|
||||||
|
"""Support attachments with generate data."""
|
106
homeassistant/components/ai_task/entity.py
Normal file
106
homeassistant/components/ai_task/entity.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""Entity for the AI Task integration."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
import contextlib
|
||||||
|
from typing import final
|
||||||
|
|
||||||
|
from propcache.api import cached_property
|
||||||
|
|
||||||
|
from homeassistant.components.conversation import (
|
||||||
|
ChatLog,
|
||||||
|
UserContent,
|
||||||
|
async_get_chat_log,
|
||||||
|
)
|
||||||
|
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||||
|
from homeassistant.helpers import llm
|
||||||
|
from homeassistant.helpers.chat_session import ChatSession
|
||||||
|
from homeassistant.helpers.restore_state import RestoreEntity
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature
|
||||||
|
from .task import GenDataTask, GenDataTaskResult
|
||||||
|
|
||||||
|
|
||||||
|
class AITaskEntity(RestoreEntity):
|
||||||
|
"""Entity that supports conversations."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_supported_features = AITaskEntityFeature(0)
|
||||||
|
__last_activity: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
@final
|
||||||
|
def state(self) -> str | None:
|
||||||
|
"""Return the state of the entity."""
|
||||||
|
if self.__last_activity is None:
|
||||||
|
return None
|
||||||
|
return self.__last_activity
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def supported_features(self) -> AITaskEntityFeature:
|
||||||
|
"""Flag supported features."""
|
||||||
|
return self._attr_supported_features
|
||||||
|
|
||||||
|
async def async_internal_added_to_hass(self) -> None:
|
||||||
|
"""Call when the entity is added to hass."""
|
||||||
|
await super().async_internal_added_to_hass()
|
||||||
|
state = await self.async_get_last_state()
|
||||||
|
if (
|
||||||
|
state is not None
|
||||||
|
and state.state is not None
|
||||||
|
and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||||
|
):
|
||||||
|
self.__last_activity = state.state
|
||||||
|
|
||||||
|
@final
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def _async_get_ai_task_chat_log(
|
||||||
|
self,
|
||||||
|
session: ChatSession,
|
||||||
|
task: GenDataTask,
|
||||||
|
) -> AsyncGenerator[ChatLog]:
|
||||||
|
"""Context manager used to manage the ChatLog used during an AI Task."""
|
||||||
|
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
||||||
|
with (
|
||||||
|
async_get_chat_log(
|
||||||
|
self.hass,
|
||||||
|
session,
|
||||||
|
None,
|
||||||
|
) as chat_log,
|
||||||
|
):
|
||||||
|
await chat_log.async_provide_llm_data(
|
||||||
|
llm.LLMContext(
|
||||||
|
platform=self.platform.domain,
|
||||||
|
context=None,
|
||||||
|
language=None,
|
||||||
|
assistant=DOMAIN,
|
||||||
|
device_id=None,
|
||||||
|
),
|
||||||
|
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_log.async_add_user_content(
|
||||||
|
UserContent(task.instructions, attachments=task.attachments)
|
||||||
|
)
|
||||||
|
|
||||||
|
yield chat_log
|
||||||
|
|
||||||
|
@final
|
||||||
|
async def internal_async_generate_data(
|
||||||
|
self,
|
||||||
|
session: ChatSession,
|
||||||
|
task: GenDataTask,
|
||||||
|
) -> GenDataTaskResult:
|
||||||
|
"""Run a gen data task."""
|
||||||
|
self.__last_activity = dt_util.utcnow().isoformat()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
async with self._async_get_ai_task_chat_log(session, task) as chat_log:
|
||||||
|
return await self._async_generate_data(task, chat_log)
|
||||||
|
|
||||||
|
async def _async_generate_data(
|
||||||
|
self,
|
||||||
|
task: GenDataTask,
|
||||||
|
chat_log: ChatLog,
|
||||||
|
) -> GenDataTaskResult:
|
||||||
|
"""Handle a gen data task."""
|
||||||
|
raise NotImplementedError
|
54
homeassistant/components/ai_task/http.py
Normal file
54
homeassistant/components/ai_task/http.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""HTTP endpoint for AI Task integration."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import websocket_api
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
|
from .const import DATA_PREFERENCES
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_setup(hass: HomeAssistant) -> None:
|
||||||
|
"""Set up the HTTP API for the conversation integration."""
|
||||||
|
websocket_api.async_register_command(hass, websocket_get_preferences)
|
||||||
|
websocket_api.async_register_command(hass, websocket_set_preferences)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "ai_task/preferences/get",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@callback
|
||||||
|
def websocket_get_preferences(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Get AI task preferences."""
|
||||||
|
preferences = hass.data[DATA_PREFERENCES]
|
||||||
|
connection.send_result(msg["id"], preferences.as_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "ai_task/preferences/set",
|
||||||
|
vol.Optional("gen_data_entity_id"): vol.Any(str, None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@callback
|
||||||
|
def websocket_set_preferences(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Set AI task preferences."""
|
||||||
|
preferences = hass.data[DATA_PREFERENCES]
|
||||||
|
msg.pop("type")
|
||||||
|
msg_id = msg.pop("id")
|
||||||
|
preferences.async_set_preferences(**msg)
|
||||||
|
connection.send_result(msg_id, preferences.as_dict())
|
7
homeassistant/components/ai_task/icons.json
Normal file
7
homeassistant/components/ai_task/icons.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"generate_data": {
|
||||||
|
"service": "mdi:file-star-four-points-outline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
homeassistant/components/ai_task/manifest.json
Normal file
10
homeassistant/components/ai_task/manifest.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"domain": "ai_task",
|
||||||
|
"name": "AI Task",
|
||||||
|
"after_dependencies": ["camera"],
|
||||||
|
"codeowners": ["@home-assistant/core"],
|
||||||
|
"dependencies": ["conversation", "media_source"],
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/ai_task",
|
||||||
|
"integration_type": "system",
|
||||||
|
"quality_scale": "internal"
|
||||||
|
}
|
33
homeassistant/components/ai_task/services.yaml
Normal file
33
homeassistant/components/ai_task/services.yaml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
generate_data:
|
||||||
|
fields:
|
||||||
|
task_name:
|
||||||
|
example: "home summary"
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
instructions:
|
||||||
|
example: "Generate a funny notification that the garage door was left open"
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
multiline: true
|
||||||
|
entity_id:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
entity:
|
||||||
|
filter:
|
||||||
|
domain: ai_task
|
||||||
|
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:
|
||||||
|
- "*"
|
30
homeassistant/components/ai_task/strings.json
Normal file
30
homeassistant/components/ai_task/strings.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"generate_data": {
|
||||||
|
"name": "Generate data",
|
||||||
|
"description": "Uses AI to run a task that generates data.",
|
||||||
|
"fields": {
|
||||||
|
"task_name": {
|
||||||
|
"name": "Task name",
|
||||||
|
"description": "Name of the task."
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"name": "Instructions",
|
||||||
|
"description": "Instructions on what needs to be done."
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "Entity ID",
|
||||||
|
"description": "Entity ID to run the task on. If not provided, the preferred entity will be used."
|
||||||
|
},
|
||||||
|
"structure": {
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
169
homeassistant/components/ai_task/task.py
Normal file
169
homeassistant/components/ai_task/task.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
"""AI tasks to be handled by agents."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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.helpers.chat_session import async_get_chat_session
|
||||||
|
|
||||||
|
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(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
*,
|
||||||
|
task_name: str,
|
||||||
|
entity_id: str | None = None,
|
||||||
|
instructions: str,
|
||||||
|
structure: vol.Schema | None = None,
|
||||||
|
attachments: list[dict] | None = None,
|
||||||
|
) -> GenDataTaskResult:
|
||||||
|
"""Run a task in the AI Task integration."""
|
||||||
|
if entity_id is None:
|
||||||
|
entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id
|
||||||
|
|
||||||
|
if entity_id is None:
|
||||||
|
raise HomeAssistantError("No entity_id provided and no preferred entity set")
|
||||||
|
|
||||||
|
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
||||||
|
if entity is None:
|
||||||
|
raise HomeAssistantError(f"AI Task entity {entity_id} not found")
|
||||||
|
|
||||||
|
if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"AI Task entity {entity_id} does not support generating data"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve attachments
|
||||||
|
resolved_attachments: list[conversation.Attachment] = []
|
||||||
|
created_files: list[Path] = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
class GenDataTask:
|
||||||
|
"""Gen data task to be processed."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
"""Name of the task."""
|
||||||
|
|
||||||
|
instructions: str
|
||||||
|
"""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:
|
||||||
|
"""Return task as a string."""
|
||||||
|
return f"<GenDataTask {self.name}: {id(self)}>"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GenDataTaskResult:
|
||||||
|
"""Result of gen data task."""
|
||||||
|
|
||||||
|
conversation_id: str
|
||||||
|
"""Unique identifier for the conversation."""
|
||||||
|
|
||||||
|
data: Any
|
||||||
|
"""Data generated by the task."""
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
"""Return result as a dict."""
|
||||||
|
return {
|
||||||
|
"conversation_id": self.conversation_id,
|
||||||
|
"data": self.data,
|
||||||
|
}
|
@ -51,9 +51,16 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
|
|||||||
|
|
||||||
async def _async_setup(self) -> None:
|
async def _async_setup(self) -> None:
|
||||||
"""Set up the coordinator."""
|
"""Set up the coordinator."""
|
||||||
|
try:
|
||||||
self._current_version = (
|
self._current_version = (
|
||||||
await self.client.get_current_measures()
|
await self.client.get_current_measures()
|
||||||
).firmware_version
|
).firmware_version
|
||||||
|
except AirGradientError as error:
|
||||||
|
raise UpdateFailed(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="update_error",
|
||||||
|
translation_placeholders={"error": str(error)},
|
||||||
|
) from error
|
||||||
|
|
||||||
async def _async_update_data(self) -> AirGradientData:
|
async def _async_update_data(self) -> AirGradientData:
|
||||||
try:
|
try:
|
||||||
|
@ -6,6 +6,7 @@ from typing import Any, Concatenate
|
|||||||
from airgradient import AirGradientConnectionError, AirGradientError, get_model_name
|
from airgradient import AirGradientConnectionError, AirGradientError, get_model_name
|
||||||
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
@ -29,6 +30,7 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]):
|
|||||||
model_id=measures.model,
|
model_id=measures.model,
|
||||||
serial_number=coordinator.serial_number,
|
serial_number=coordinator.serial_number,
|
||||||
sw_version=measures.firmware_version,
|
sw_version=measures.firmware_version,
|
||||||
|
connections={(dr.CONNECTION_NETWORK_MAC, coordinator.serial_number)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -39,14 +39,14 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
try:
|
try:
|
||||||
location_point_valid = await test_location(
|
location_point_valid = await check_location(
|
||||||
websession,
|
websession,
|
||||||
user_input["api_key"],
|
user_input["api_key"],
|
||||||
user_input["latitude"],
|
user_input["latitude"],
|
||||||
user_input["longitude"],
|
user_input["longitude"],
|
||||||
)
|
)
|
||||||
if not location_point_valid:
|
if not location_point_valid:
|
||||||
location_nearest_valid = await test_location(
|
location_nearest_valid = await check_location(
|
||||||
websession,
|
websession,
|
||||||
user_input["api_key"],
|
user_input["api_key"],
|
||||||
user_input["latitude"],
|
user_input["latitude"],
|
||||||
@ -88,7 +88,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_location(
|
async def check_location(
|
||||||
client: ClientSession,
|
client: ClientSession,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
latitude: float,
|
latitude: float,
|
||||||
|
@ -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,
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["aioairq"],
|
"loggers": ["aioairq"],
|
||||||
"requirements": ["aioairq==0.4.4"]
|
"requirements": ["aioairq==0.4.6"]
|
||||||
}
|
}
|
||||||
|
@ -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"),
|
||||||
|
@ -93,9 +93,6 @@
|
|||||||
"health_index": {
|
"health_index": {
|
||||||
"name": "Health index"
|
"name": "Health index"
|
||||||
},
|
},
|
||||||
"absolute_humidity": {
|
|
||||||
"name": "Absolute humidity"
|
|
||||||
},
|
|
||||||
"hydrogen": {
|
"hydrogen": {
|
||||||
"name": "Hydrogen"
|
"name": "Hydrogen"
|
||||||
},
|
},
|
||||||
|
@ -5,23 +5,22 @@ from __future__ import annotations
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from airthings import Airthings, AirthingsDevice, AirthingsError
|
from airthings import Airthings
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
||||||
|
|
||||||
from .const import CONF_SECRET, DOMAIN
|
from .const import CONF_SECRET
|
||||||
|
from .coordinator import 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 AirthingsDataCoordinatorType = DataUpdateCoordinator[dict[str, AirthingsDevice]]
|
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
|
||||||
type AirthingsConfigEntry = ConfigEntry[AirthingsDataCoordinatorType]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool:
|
||||||
@ -32,21 +31,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) ->
|
|||||||
async_get_clientsession(hass),
|
async_get_clientsession(hass),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _update_method() -> dict[str, AirthingsDevice]:
|
coordinator = AirthingsDataUpdateCoordinator(hass, airthings)
|
||||||
"""Get the latest data from Airthings."""
|
|
||||||
try:
|
|
||||||
return await airthings.update_devices() # type: ignore[no-any-return]
|
|
||||||
except AirthingsError as err:
|
|
||||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
|
||||||
|
|
||||||
coordinator = DataUpdateCoordinator(
|
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
config_entry=entry,
|
|
||||||
name=DOMAIN,
|
|
||||||
update_method=_update_method,
|
|
||||||
update_interval=SCAN_INTERVAL,
|
|
||||||
)
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
36
homeassistant/components/airthings/coordinator.py
Normal file
36
homeassistant/components/airthings/coordinator.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""The Airthings integration."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from airthings import Airthings, AirthingsDevice, AirthingsError
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
SCAN_INTERVAL = timedelta(minutes=6)
|
||||||
|
|
||||||
|
|
||||||
|
class AirthingsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AirthingsDevice]]):
|
||||||
|
"""Coordinator for Airthings data updates."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, airthings: Airthings) -> None:
|
||||||
|
"""Initialize the coordinator."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_method=self._update_method,
|
||||||
|
update_interval=SCAN_INTERVAL,
|
||||||
|
)
|
||||||
|
self.airthings = airthings
|
||||||
|
|
||||||
|
async def _update_method(self) -> dict[str, AirthingsDevice]:
|
||||||
|
"""Get the latest data from Airthings."""
|
||||||
|
try:
|
||||||
|
return await self.airthings.update_devices() # type: ignore[no-any-return]
|
||||||
|
except AirthingsError as err:
|
||||||
|
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
@ -3,6 +3,19 @@
|
|||||||
"name": "Airthings",
|
"name": "Airthings",
|
||||||
"codeowners": ["@danielhiversen", "@LaStrada"],
|
"codeowners": ["@danielhiversen", "@LaStrada"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
|
"dhcp": [
|
||||||
|
{
|
||||||
|
"hostname": "airthings-view"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hostname": "airthings-hub",
|
||||||
|
"macaddress": "D0141190*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hostname": "airthings-hub",
|
||||||
|
"macaddress": "70B3D52A0*"
|
||||||
|
}
|
||||||
|
],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/airthings",
|
"documentation": "https://www.home-assistant.io/integrations/airthings",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["airthings"],
|
"loggers": ["airthings"],
|
||||||
|
@ -14,10 +14,12 @@ from homeassistant.const import (
|
|||||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
CONCENTRATION_PARTS_PER_BILLION,
|
CONCENTRATION_PARTS_PER_BILLION,
|
||||||
CONCENTRATION_PARTS_PER_MILLION,
|
CONCENTRATION_PARTS_PER_MILLION,
|
||||||
|
LIGHT_LUX,
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
SIGNAL_STRENGTH_DECIBELS,
|
SIGNAL_STRENGTH_DECIBELS,
|
||||||
EntityCategory,
|
EntityCategory,
|
||||||
UnitOfPressure,
|
UnitOfPressure,
|
||||||
|
UnitOfSoundPressure,
|
||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@ -26,32 +28,44 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from . import AirthingsConfigEntry, AirthingsDataCoordinatorType
|
from . import AirthingsConfigEntry
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .coordinator import AirthingsDataUpdateCoordinator
|
||||||
|
|
||||||
SENSORS: dict[str, SensorEntityDescription] = {
|
SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
"radonShortTermAvg": SensorEntityDescription(
|
"radonShortTermAvg": SensorEntityDescription(
|
||||||
key="radonShortTermAvg",
|
key="radonShortTermAvg",
|
||||||
native_unit_of_measurement="Bq/m³",
|
native_unit_of_measurement="Bq/m³",
|
||||||
translation_key="radon",
|
translation_key="radon",
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"temp": SensorEntityDescription(
|
"temp": SensorEntityDescription(
|
||||||
key="temp",
|
key="temp",
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=1,
|
||||||
),
|
),
|
||||||
"humidity": SensorEntityDescription(
|
"humidity": SensorEntityDescription(
|
||||||
key="humidity",
|
key="humidity",
|
||||||
device_class=SensorDeviceClass.HUMIDITY,
|
device_class=SensorDeviceClass.HUMIDITY,
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"pressure": SensorEntityDescription(
|
"pressure": SensorEntityDescription(
|
||||||
key="pressure",
|
key="pressure",
|
||||||
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
||||||
native_unit_of_measurement=UnitOfPressure.MBAR,
|
native_unit_of_measurement=UnitOfPressure.MBAR,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=1,
|
||||||
|
),
|
||||||
|
"sla": SensorEntityDescription(
|
||||||
|
key="sla",
|
||||||
|
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
||||||
|
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"battery": SensorEntityDescription(
|
"battery": SensorEntityDescription(
|
||||||
key="battery",
|
key="battery",
|
||||||
@ -59,34 +73,47 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"co2": SensorEntityDescription(
|
"co2": SensorEntityDescription(
|
||||||
key="co2",
|
key="co2",
|
||||||
device_class=SensorDeviceClass.CO2,
|
device_class=SensorDeviceClass.CO2,
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"voc": SensorEntityDescription(
|
"voc": SensorEntityDescription(
|
||||||
key="voc",
|
key="voc",
|
||||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"light": SensorEntityDescription(
|
"light": SensorEntityDescription(
|
||||||
key="light",
|
key="light",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
translation_key="light",
|
translation_key="light",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
),
|
||||||
|
"lux": SensorEntityDescription(
|
||||||
|
key="lux",
|
||||||
|
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||||
|
native_unit_of_measurement=LIGHT_LUX,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"virusRisk": SensorEntityDescription(
|
"virusRisk": SensorEntityDescription(
|
||||||
key="virusRisk",
|
key="virusRisk",
|
||||||
translation_key="virus_risk",
|
translation_key="virus_risk",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"mold": SensorEntityDescription(
|
"mold": SensorEntityDescription(
|
||||||
key="mold",
|
key="mold",
|
||||||
translation_key="mold",
|
translation_key="mold",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"rssi": SensorEntityDescription(
|
"rssi": SensorEntityDescription(
|
||||||
key="rssi",
|
key="rssi",
|
||||||
@ -95,18 +122,21 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"pm1": SensorEntityDescription(
|
"pm1": SensorEntityDescription(
|
||||||
key="pm1",
|
key="pm1",
|
||||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
device_class=SensorDeviceClass.PM1,
|
device_class=SensorDeviceClass.PM1,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"pm25": SensorEntityDescription(
|
"pm25": SensorEntityDescription(
|
||||||
key="pm25",
|
key="pm25",
|
||||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
device_class=SensorDeviceClass.PM25,
|
device_class=SensorDeviceClass.PM25,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,7 +163,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
class AirthingsHeaterEnergySensor(
|
class AirthingsHeaterEnergySensor(
|
||||||
CoordinatorEntity[AirthingsDataCoordinatorType], SensorEntity
|
CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity
|
||||||
):
|
):
|
||||||
"""Representation of a Airthings Sensor device."""
|
"""Representation of a Airthings Sensor device."""
|
||||||
|
|
||||||
@ -142,7 +172,7 @@ class AirthingsHeaterEnergySensor(
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: AirthingsDataCoordinatorType,
|
coordinator: AirthingsDataUpdateCoordinator,
|
||||||
airthings_device: AirthingsDevice,
|
airthings_device: AirthingsDevice,
|
||||||
entity_description: SensorEntityDescription,
|
entity_description: SensorEntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -142,7 +142,7 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
|
|||||||
return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode]
|
return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hvac_modes(self):
|
def hvac_modes(self) -> list[HVACMode]:
|
||||||
"""Return the list of available operation modes."""
|
"""Return the list of available operation modes."""
|
||||||
airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number)
|
airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number)
|
||||||
modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes]
|
modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes]
|
||||||
@ -226,12 +226,12 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
|||||||
return super()._handle_coordinator_update()
|
return super()._handle_coordinator_update()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min_temp(self):
|
def min_temp(self) -> float:
|
||||||
"""Return Minimum Temperature for AC of this group."""
|
"""Return Minimum Temperature for AC of this group."""
|
||||||
return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint
|
return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_temp(self):
|
def max_temp(self) -> float:
|
||||||
"""Return Max Temperature for AC of this group."""
|
"""Return Max Temperature for AC of this group."""
|
||||||
return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint
|
return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint
|
||||||
|
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
|
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["airtouch5py"],
|
"loggers": ["airtouch5py"],
|
||||||
"requirements": ["airtouch5py==0.2.11"]
|
"requirements": ["airtouch5py==0.3.0"]
|
||||||
}
|
}
|
||||||
|
@ -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.11"]
|
"requirements": ["aioairzone-cloud==0.6.13"]
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Choose AlarmDecoder Protocol",
|
"title": "Choose AlarmDecoder protocol",
|
||||||
"data": {
|
"data": {
|
||||||
"protocol": "Protocol"
|
"protocol": "Protocol"
|
||||||
}
|
}
|
||||||
@ -12,8 +12,8 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"host": "[%key:common::config_flow::data::host%]",
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
"port": "[%key:common::config_flow::data::port%]",
|
"port": "[%key:common::config_flow::data::port%]",
|
||||||
"device_baudrate": "Device Baud Rate",
|
"device_baudrate": "Device baud rate",
|
||||||
"device_path": "Device Path"
|
"device_path": "Device path"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.",
|
"host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.",
|
||||||
@ -44,36 +44,36 @@
|
|||||||
"arm_settings": {
|
"arm_settings": {
|
||||||
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
||||||
"data": {
|
"data": {
|
||||||
"auto_bypass": "Auto Bypass on Arm",
|
"auto_bypass": "Auto-bypass on arm",
|
||||||
"code_arm_required": "Code Required for Arming",
|
"code_arm_required": "Code required for arming",
|
||||||
"alt_night_mode": "Alternative Night Mode"
|
"alt_night_mode": "Alternative night mode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"zone_select": {
|
"zone_select": {
|
||||||
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
||||||
"description": "Enter the zone number you'd like to to add, edit, or remove.",
|
"description": "Enter the zone number you'd like to to add, edit, or remove.",
|
||||||
"data": {
|
"data": {
|
||||||
"zone_number": "Zone Number"
|
"zone_number": "Zone number"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"zone_details": {
|
"zone_details": {
|
||||||
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
||||||
"description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave Zone Name blank.",
|
"description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave 'Zone name' blank.",
|
||||||
"data": {
|
"data": {
|
||||||
"zone_name": "Zone Name",
|
"zone_name": "Zone name",
|
||||||
"zone_type": "Zone Type",
|
"zone_type": "Zone type",
|
||||||
"zone_rfid": "RF Serial",
|
"zone_rfid": "RF serial",
|
||||||
"zone_loop": "RF Loop",
|
"zone_loop": "RF loop",
|
||||||
"zone_relayaddr": "Relay Address",
|
"zone_relayaddr": "Relay address",
|
||||||
"zone_relaychan": "Relay Channel"
|
"zone_relaychan": "Relay channel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"relay_inclusive": "Relay Address and Relay Channel are codependent and must be included together.",
|
"relay_inclusive": "'Relay address' and 'Relay channel' are codependent and must be included together.",
|
||||||
"int": "The field below must be an integer.",
|
"int": "The field below must be an integer.",
|
||||||
"loop_rfid": "RF Loop cannot be used without RF Serial.",
|
"loop_rfid": "'RF loop' cannot be used without 'RF serial'.",
|
||||||
"loop_range": "RF Loop must be an integer between 1 and 4."
|
"loop_range": "'RF loop' must be an integer between 1 and 4."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
|
@ -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}"
|
||||||
)
|
)
|
||||||
@ -719,7 +732,7 @@ class LockCapabilities(AlexaEntity):
|
|||||||
yield Alexa(self.entity)
|
yield Alexa(self.entity)
|
||||||
|
|
||||||
|
|
||||||
@ENTITY_ADAPTERS.register(media_player.const.DOMAIN)
|
@ENTITY_ADAPTERS.register(media_player.DOMAIN)
|
||||||
class MediaPlayerCapabilities(AlexaEntity):
|
class MediaPlayerCapabilities(AlexaEntity):
|
||||||
"""Class to represent MediaPlayer capabilities."""
|
"""Class to represent MediaPlayer capabilities."""
|
||||||
|
|
||||||
@ -757,9 +770,7 @@ class MediaPlayerCapabilities(AlexaEntity):
|
|||||||
|
|
||||||
if supported & media_player.MediaPlayerEntityFeature.SELECT_SOURCE:
|
if supported & media_player.MediaPlayerEntityFeature.SELECT_SOURCE:
|
||||||
inputs = AlexaInputController.get_valid_inputs(
|
inputs = AlexaInputController.get_valid_inputs(
|
||||||
self.entity.attributes.get(
|
self.entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST, [])
|
||||||
media_player.const.ATTR_INPUT_SOURCE_LIST, []
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if len(inputs) > 0:
|
if len(inputs) > 0:
|
||||||
yield AlexaInputController(self.entity)
|
yield AlexaInputController(self.entity)
|
||||||
@ -776,8 +787,7 @@ class MediaPlayerCapabilities(AlexaEntity):
|
|||||||
and domain != "denonavr"
|
and domain != "denonavr"
|
||||||
):
|
):
|
||||||
inputs = AlexaEqualizerController.get_valid_inputs(
|
inputs = AlexaEqualizerController.get_valid_inputs(
|
||||||
self.entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST)
|
self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) or []
|
||||||
or []
|
|
||||||
)
|
)
|
||||||
if len(inputs) > 0:
|
if len(inputs) > 0:
|
||||||
yield AlexaEqualizerController(self.entity)
|
yield AlexaEqualizerController(self.entity)
|
||||||
|
@ -566,7 +566,7 @@ async def async_api_set_volume(
|
|||||||
|
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
ATTR_ENTITY_ID: entity.entity_id,
|
ATTR_ENTITY_ID: entity.entity_id,
|
||||||
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
media_player.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
||||||
}
|
}
|
||||||
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
@ -589,7 +589,7 @@ async def async_api_select_input(
|
|||||||
|
|
||||||
# Attempt to map the ALL UPPERCASE payload name to a source.
|
# Attempt to map the ALL UPPERCASE payload name to a source.
|
||||||
# Strips trailing 1 to match single input devices.
|
# Strips trailing 1 to match single input devices.
|
||||||
source_list = entity.attributes.get(media_player.const.ATTR_INPUT_SOURCE_LIST) or []
|
source_list = entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or []
|
||||||
for source in source_list:
|
for source in source_list:
|
||||||
formatted_source = (
|
formatted_source = (
|
||||||
source.lower().replace("-", "").replace("_", "").replace(" ", "")
|
source.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||||
@ -611,7 +611,7 @@ async def async_api_select_input(
|
|||||||
|
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
ATTR_ENTITY_ID: entity.entity_id,
|
ATTR_ENTITY_ID: entity.entity_id,
|
||||||
media_player.const.ATTR_INPUT_SOURCE: media_input,
|
media_player.ATTR_INPUT_SOURCE: media_input,
|
||||||
}
|
}
|
||||||
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
@ -636,7 +636,7 @@ async def async_api_adjust_volume(
|
|||||||
volume_delta = int(directive.payload["volume"])
|
volume_delta = int(directive.payload["volume"])
|
||||||
|
|
||||||
entity = directive.entity
|
entity = directive.entity
|
||||||
current_level = entity.attributes[media_player.const.ATTR_MEDIA_VOLUME_LEVEL]
|
current_level = entity.attributes[media_player.ATTR_MEDIA_VOLUME_LEVEL]
|
||||||
|
|
||||||
# read current state
|
# read current state
|
||||||
try:
|
try:
|
||||||
@ -648,7 +648,7 @@ async def async_api_adjust_volume(
|
|||||||
|
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
ATTR_ENTITY_ID: entity.entity_id,
|
ATTR_ENTITY_ID: entity.entity_id,
|
||||||
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
media_player.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
||||||
}
|
}
|
||||||
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
@ -709,7 +709,7 @@ async def async_api_set_mute(
|
|||||||
entity = directive.entity
|
entity = directive.entity
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
ATTR_ENTITY_ID: entity.entity_id,
|
ATTR_ENTITY_ID: entity.entity_id,
|
||||||
media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute,
|
media_player.ATTR_MEDIA_VOLUME_MUTED: mute,
|
||||||
}
|
}
|
||||||
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
@ -1708,15 +1708,13 @@ async def async_api_changechannel(
|
|||||||
|
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
ATTR_ENTITY_ID: entity.entity_id,
|
ATTR_ENTITY_ID: entity.entity_id,
|
||||||
media_player.const.ATTR_MEDIA_CONTENT_ID: channel,
|
media_player.ATTR_MEDIA_CONTENT_ID: channel,
|
||||||
media_player.const.ATTR_MEDIA_CONTENT_TYPE: (
|
media_player.ATTR_MEDIA_CONTENT_TYPE: (media_player.MediaType.CHANNEL),
|
||||||
media_player.const.MEDIA_TYPE_CHANNEL
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
entity.domain,
|
entity.domain,
|
||||||
media_player.const.SERVICE_PLAY_MEDIA,
|
media_player.SERVICE_PLAY_MEDIA,
|
||||||
data,
|
data,
|
||||||
blocking=False,
|
blocking=False,
|
||||||
context=context,
|
context=context,
|
||||||
@ -1825,13 +1823,13 @@ async def async_api_set_eq_mode(
|
|||||||
context: ha.Context,
|
context: ha.Context,
|
||||||
) -> AlexaResponse:
|
) -> AlexaResponse:
|
||||||
"""Process a SetMode request for EqualizerController."""
|
"""Process a SetMode request for EqualizerController."""
|
||||||
mode = directive.payload["mode"]
|
mode: str = directive.payload["mode"]
|
||||||
entity = directive.entity
|
entity = directive.entity
|
||||||
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
|
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
|
||||||
|
|
||||||
sound_mode_list = entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST)
|
sound_mode_list = entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST)
|
||||||
if sound_mode_list and mode.lower() in sound_mode_list:
|
if sound_mode_list and mode.lower() in sound_mode_list:
|
||||||
data[media_player.const.ATTR_SOUND_MODE] = mode.lower()
|
data[media_player.ATTR_SOUND_MODE] = mode.lower()
|
||||||
else:
|
else:
|
||||||
msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}"
|
msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}"
|
||||||
raise AlexaInvalidValueError(msg)
|
raise AlexaInvalidValueError(msg)
|
||||||
|
@ -3,10 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from asyncio import timeout
|
from asyncio import timeout
|
||||||
|
from collections.abc import Mapping
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from types import MappingProxyType
|
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@ -260,10 +260,10 @@ async def async_enable_proactive_mode(
|
|||||||
def extra_significant_check(
|
def extra_significant_check(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
old_state: str,
|
old_state: str,
|
||||||
old_attrs: dict[Any, Any] | MappingProxyType[Any, Any],
|
old_attrs: Mapping[Any, Any],
|
||||||
old_extra_arg: Any,
|
old_extra_arg: Any,
|
||||||
new_state: str,
|
new_state: str,
|
||||||
new_attrs: dict[str, Any] | MappingProxyType[Any, Any],
|
new_attrs: Mapping[Any, Any],
|
||||||
new_extra_arg: Any,
|
new_extra_arg: Any,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if the serialized data has changed."""
|
"""Check if the serialized data has changed."""
|
||||||
|
36
homeassistant/components/alexa_devices/__init__.py
Normal file
36
homeassistant/components/alexa_devices/__init__.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""Alexa Devices integration."""
|
||||||
|
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||||
|
|
||||||
|
PLATFORMS = [
|
||||||
|
Platform.BINARY_SENSOR,
|
||||||
|
Platform.NOTIFY,
|
||||||
|
Platform.SENSOR,
|
||||||
|
Platform.SWITCH,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||||
|
"""Set up Alexa Devices platform."""
|
||||||
|
|
||||||
|
coordinator = AmazonDevicesCoordinator(hass, entry)
|
||||||
|
|
||||||
|
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: AmazonConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
await coordinator.api.close()
|
||||||
|
|
||||||
|
return unload_ok
|
115
homeassistant/components/alexa_devices/binary_sensor.py
Normal file
115
homeassistant/components/alexa_devices/binary_sensor.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""Support for binary sensors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from aioamazondevices.api import AmazonDevice
|
||||||
|
from aioamazondevices.const import SENSOR_STATE_OFF
|
||||||
|
|
||||||
|
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 AmazonConfigEntry
|
||||||
|
from .entity import AmazonEntity
|
||||||
|
|
||||||
|
# Coordinator is used to centralize the data updates
|
||||||
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||||
|
"""Alexa Devices binary sensor entity description."""
|
||||||
|
|
||||||
|
is_on_fn: Callable[[AmazonDevice, str], bool]
|
||||||
|
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
|
||||||
|
|
||||||
|
|
||||||
|
BINARY_SENSORS: Final = (
|
||||||
|
AmazonBinarySensorEntityDescription(
|
||||||
|
key="online",
|
||||||
|
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
is_on_fn=lambda device, _: device.online,
|
||||||
|
),
|
||||||
|
AmazonBinarySensorEntityDescription(
|
||||||
|
key="bluetooth",
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
translation_key="bluetooth",
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AmazonConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Alexa Devices binary sensors based on a config entry."""
|
||||||
|
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||||
|
for sensor_desc in BINARY_SENSORS
|
||||||
|
for serial_num in coordinator.data
|
||||||
|
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
||||||
|
"""Binary sensor device."""
|
||||||
|
|
||||||
|
entity_description: AmazonBinarySensorEntityDescription
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return True if the binary sensor is on."""
|
||||||
|
return self.entity_description.is_on_fn(
|
||||||
|
self.device, self.entity_description.key
|
||||||
|
)
|
133
homeassistant/components/alexa_devices/config_flow.py
Normal file
133
homeassistant/components/alexa_devices/config_flow.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"""Config flow for Alexa Devices integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aioamazondevices.api import AmazonEchoApi
|
||||||
|
from aioamazondevices.exceptions import (
|
||||||
|
CannotAuthenticate,
|
||||||
|
CannotConnect,
|
||||||
|
CannotRetrieveData,
|
||||||
|
WrongCountry,
|
||||||
|
)
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant.helpers.selector import CountrySelector
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
api = AmazonEchoApi(
|
||||||
|
data[CONF_COUNTRY],
|
||||||
|
data[CONF_USERNAME],
|
||||||
|
data[CONF_PASSWORD],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await api.login_mode_interactive(data[CONF_CODE])
|
||||||
|
finally:
|
||||||
|
await api.close()
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Alexa Devices."""
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors = {}
|
||||||
|
if user_input:
|
||||||
|
try:
|
||||||
|
data = await validate_input(self.hass, user_input)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except CannotAuthenticate:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except CannotRetrieveData:
|
||||||
|
errors["base"] = "cannot_retrieve_data"
|
||||||
|
except WrongCountry:
|
||||||
|
errors["base"] = "wrong_country"
|
||||||
|
else:
|
||||||
|
await self.async_set_unique_id(data["customer_info"]["user_id"])
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
user_input.pop(CONF_CODE)
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=user_input[CONF_USERNAME],
|
||||||
|
data=user_input | {CONF_LOGIN_DATA: data},
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
errors=errors,
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(
|
||||||
|
CONF_COUNTRY, default=self.hass.config.country
|
||||||
|
): CountrySelector(),
|
||||||
|
vol.Required(CONF_USERNAME): cv.string,
|
||||||
|
vol.Required(CONF_PASSWORD): cv.string,
|
||||||
|
vol.Required(CONF_CODE): cv.string,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
8
homeassistant/components/alexa_devices/const.py
Normal file
8
homeassistant/components/alexa_devices/const.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"""Alexa Devices constants."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
|
DOMAIN = "alexa_devices"
|
||||||
|
CONF_LOGIN_DATA = "login_data"
|
72
homeassistant/components/alexa_devices/coordinator.py
Normal file
72
homeassistant/components/alexa_devices/coordinator.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""Support for Alexa Devices."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
||||||
|
from aioamazondevices.exceptions import (
|
||||||
|
CannotAuthenticate,
|
||||||
|
CannotConnect,
|
||||||
|
CannotRetrieveData,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
|
||||||
|
|
||||||
|
SCAN_INTERVAL = 30
|
||||||
|
|
||||||
|
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
|
||||||
|
|
||||||
|
|
||||||
|
class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||||
|
"""Base coordinator for Alexa Devices."""
|
||||||
|
|
||||||
|
config_entry: AmazonConfigEntry
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AmazonConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the scanner."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name=entry.title,
|
||||||
|
config_entry=entry,
|
||||||
|
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||||
|
)
|
||||||
|
self.api = AmazonEchoApi(
|
||||||
|
entry.data[CONF_COUNTRY],
|
||||||
|
entry.data[CONF_USERNAME],
|
||||||
|
entry.data[CONF_PASSWORD],
|
||||||
|
entry.data[CONF_LOGIN_DATA],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||||
|
"""Update device data."""
|
||||||
|
try:
|
||||||
|
await self.api.login_mode_stored_data()
|
||||||
|
return await self.api.get_devices_data()
|
||||||
|
except CannotConnect as 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:
|
||||||
|
raise ConfigEntryAuthFailed(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="invalid_auth",
|
||||||
|
translation_placeholders={"error": repr(err)},
|
||||||
|
) from err
|
66
homeassistant/components/alexa_devices/diagnostics.py
Normal file
66
homeassistant/components/alexa_devices/diagnostics.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""Diagnostics support for Alexa Devices integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aioamazondevices.api import AmazonDevice
|
||||||
|
|
||||||
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
|
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.device_registry import DeviceEntry
|
||||||
|
|
||||||
|
from .coordinator import AmazonConfigEntry
|
||||||
|
|
||||||
|
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_config_entry_diagnostics(
|
||||||
|
hass: HomeAssistant, entry: AmazonConfigEntry
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return diagnostics for a config entry."""
|
||||||
|
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
|
devices: list[dict[str, dict[str, Any]]] = [
|
||||||
|
build_device_data(device) for device in coordinator.data.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||||
|
"device_info": {
|
||||||
|
"last_update success": coordinator.last_update_success,
|
||||||
|
"last_exception": repr(coordinator.last_exception),
|
||||||
|
"devices": devices,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_device_diagnostics(
|
||||||
|
hass: HomeAssistant, entry: AmazonConfigEntry, device_entry: DeviceEntry
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return diagnostics for a device."""
|
||||||
|
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
|
assert device_entry.serial_number
|
||||||
|
|
||||||
|
return build_device_data(coordinator.data[device_entry.serial_number])
|
||||||
|
|
||||||
|
|
||||||
|
def build_device_data(device: AmazonDevice) -> dict[str, Any]:
|
||||||
|
"""Build device data for diagnostics."""
|
||||||
|
return {
|
||||||
|
"account name": device.account_name,
|
||||||
|
"capabilities": device.capabilities,
|
||||||
|
"device family": device.device_family,
|
||||||
|
"device type": device.device_type,
|
||||||
|
"device cluster members": device.device_cluster_members,
|
||||||
|
"online": device.online,
|
||||||
|
"serial number": device.serial_number,
|
||||||
|
"software version": device.software_version,
|
||||||
|
"do not disturb": device.do_not_disturb,
|
||||||
|
"response style": device.response_style,
|
||||||
|
"bluetooth state": device.bluetooth_state,
|
||||||
|
}
|
57
homeassistant/components/alexa_devices/entity.py
Normal file
57
homeassistant/components/alexa_devices/entity.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""Defines a base Alexa Devices entity."""
|
||||||
|
|
||||||
|
from aioamazondevices.api import AmazonDevice
|
||||||
|
from aioamazondevices.const import SPEAKER_GROUP_MODEL
|
||||||
|
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import AmazonDevicesCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
|
||||||
|
"""Defines a base Alexa Devices entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: AmazonDevicesCoordinator,
|
||||||
|
serial_num: str,
|
||||||
|
description: EntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._serial_num = serial_num
|
||||||
|
model_details = coordinator.api.get_model_details(self.device) or {}
|
||||||
|
model = model_details.get("model")
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, serial_num)},
|
||||||
|
name=self.device.account_name,
|
||||||
|
model=model,
|
||||||
|
model_id=self.device.device_type,
|
||||||
|
manufacturer=model_details.get("manufacturer", "Amazon"),
|
||||||
|
hw_version=model_details.get("hw_version"),
|
||||||
|
sw_version=(
|
||||||
|
self.device.software_version if model != SPEAKER_GROUP_MODEL else None
|
||||||
|
),
|
||||||
|
serial_number=serial_num if model != SPEAKER_GROUP_MODEL else None,
|
||||||
|
)
|
||||||
|
self.entity_description = description
|
||||||
|
self._attr_unique_id = f"{serial_num}-{description.key}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device(self) -> AmazonDevice:
|
||||||
|
"""Return the device."""
|
||||||
|
return self.coordinator.data[self._serial_num]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True if entity is available."""
|
||||||
|
return (
|
||||||
|
super().available
|
||||||
|
and self._serial_num in self.coordinator.data
|
||||||
|
and self.device.online
|
||||||
|
)
|
42
homeassistant/components/alexa_devices/icons.json
Normal file
42
homeassistant/components/alexa_devices/icons.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"entity": {
|
||||||
|
"binary_sensor": {
|
||||||
|
"bluetooth": {
|
||||||
|
"default": "mdi:bluetooth-off",
|
||||||
|
"state": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
homeassistant/components/alexa_devices/manifest.json
Normal file
12
homeassistant/components/alexa_devices/manifest.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"domain": "alexa_devices",
|
||||||
|
"name": "Alexa Devices",
|
||||||
|
"codeowners": ["@chemelli74"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/alexa_devices",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"iot_class": "cloud_polling",
|
||||||
|
"loggers": ["aioamazondevices"],
|
||||||
|
"quality_scale": "silver",
|
||||||
|
"requirements": ["aioamazondevices==3.2.10"]
|
||||||
|
}
|
80
homeassistant/components/alexa_devices/notify.py
Normal file
80
homeassistant/components/alexa_devices/notify.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"""Support for notification entity."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
||||||
|
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
|
||||||
|
|
||||||
|
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from .coordinator import AmazonConfigEntry
|
||||||
|
from .entity import AmazonEntity
|
||||||
|
from .utils import alexa_api_call
|
||||||
|
|
||||||
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class AmazonNotifyEntityDescription(NotifyEntityDescription):
|
||||||
|
"""Alexa Devices notify entity description."""
|
||||||
|
|
||||||
|
is_supported: Callable[[AmazonDevice], bool] = lambda _device: True
|
||||||
|
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
|
||||||
|
subkey: str
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFY: Final = (
|
||||||
|
AmazonNotifyEntityDescription(
|
||||||
|
key="speak",
|
||||||
|
translation_key="speak",
|
||||||
|
subkey="AUDIO_PLAYER",
|
||||||
|
is_supported=lambda _device: _device.device_family != SPEAKER_GROUP_FAMILY,
|
||||||
|
method=lambda api, device, message: api.call_alexa_speak(device, message),
|
||||||
|
),
|
||||||
|
AmazonNotifyEntityDescription(
|
||||||
|
key="announce",
|
||||||
|
translation_key="announce",
|
||||||
|
subkey="AUDIO_PLAYER",
|
||||||
|
method=lambda api, device, message: api.call_alexa_announcement(
|
||||||
|
device, message
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AmazonConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Alexa Devices notification entity based on a config entry."""
|
||||||
|
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
|
||||||
|
for sensor_desc in NOTIFY
|
||||||
|
for serial_num in coordinator.data
|
||||||
|
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
||||||
|
and sensor_desc.is_supported(coordinator.data[serial_num])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
|
||||||
|
"""Binary sensor notify platform."""
|
||||||
|
|
||||||
|
entity_description: AmazonNotifyEntityDescription
|
||||||
|
|
||||||
|
@alexa_api_call
|
||||||
|
async def async_send_message(
|
||||||
|
self, message: str, title: str | None = None, **kwargs: Any
|
||||||
|
) -> None:
|
||||||
|
"""Send a message."""
|
||||||
|
|
||||||
|
await self.entity_description.method(self.coordinator.api, self.device, message)
|
74
homeassistant/components/alexa_devices/quality_scale.yaml
Normal file
74
homeassistant/components/alexa_devices/quality_scale.yaml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
rules:
|
||||||
|
# Bronze
|
||||||
|
action-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: no 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: no actions
|
||||||
|
docs-high-level-description: done
|
||||||
|
docs-installation-instructions: done
|
||||||
|
docs-removal-instructions: done
|
||||||
|
entity-event-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: entities do not explicitly subscribe to 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: done
|
||||||
|
config-entry-unloading: done
|
||||||
|
docs-configuration-parameters: done
|
||||||
|
docs-installation-parameters: done
|
||||||
|
entity-unavailable: done
|
||||||
|
integration-owner: done
|
||||||
|
log-when-unavailable: done
|
||||||
|
parallel-updates: done
|
||||||
|
reauthentication-flow: done
|
||||||
|
test-coverage: done
|
||||||
|
|
||||||
|
# Gold
|
||||||
|
devices: done
|
||||||
|
diagnostics: done
|
||||||
|
discovery-update-info:
|
||||||
|
status: exempt
|
||||||
|
comment: Network information not relevant
|
||||||
|
discovery:
|
||||||
|
status: exempt
|
||||||
|
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: done
|
||||||
|
docs-examples: done
|
||||||
|
docs-known-limitations: todo
|
||||||
|
docs-supported-devices: done
|
||||||
|
docs-supported-functions: done
|
||||||
|
docs-troubleshooting: todo
|
||||||
|
docs-use-cases: done
|
||||||
|
dynamic-devices: todo
|
||||||
|
entity-category: done
|
||||||
|
entity-device-class: done
|
||||||
|
entity-disabled-by-default: done
|
||||||
|
entity-translations: done
|
||||||
|
exception-translations: todo
|
||||||
|
icon-translations: done
|
||||||
|
reconfiguration-flow: todo
|
||||||
|
repair-issues:
|
||||||
|
status: exempt
|
||||||
|
comment: no known use cases for repair issues or flows, yet
|
||||||
|
stale-devices:
|
||||||
|
status: todo
|
||||||
|
comment: automate the cleanup process
|
||||||
|
|
||||||
|
# Platinum
|
||||||
|
async-dependency: done
|
||||||
|
inject-websession: todo
|
||||||
|
strict-typing: done
|
88
homeassistant/components/alexa_devices/sensor.py
Normal file
88
homeassistant/components/alexa_devices/sensor.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
"""Support for sensors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from aioamazondevices.api import AmazonDevice
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
)
|
||||||
|
from homeassistant.const import LIGHT_LUX, UnitOfTemperature
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import StateType
|
||||||
|
|
||||||
|
from .coordinator import AmazonConfigEntry
|
||||||
|
from .entity import AmazonEntity
|
||||||
|
|
||||||
|
# Coordinator is used to centralize the data updates
|
||||||
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class AmazonSensorEntityDescription(SensorEntityDescription):
|
||||||
|
"""Amazon Devices sensor entity description."""
|
||||||
|
|
||||||
|
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
SENSORS: Final = (
|
||||||
|
AmazonSensorEntityDescription(
|
||||||
|
key="temperature",
|
||||||
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
|
native_unit_of_measurement_fn=lambda device, _key: (
|
||||||
|
UnitOfTemperature.CELSIUS
|
||||||
|
if device.sensors[_key].scale == "CELSIUS"
|
||||||
|
else UnitOfTemperature.FAHRENHEIT
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AmazonSensorEntityDescription(
|
||||||
|
key="illuminance",
|
||||||
|
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||||
|
native_unit_of_measurement=LIGHT_LUX,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AmazonConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Amazon Devices sensors based on a config entry."""
|
||||||
|
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
|
||||||
|
for sensor_desc in SENSORS
|
||||||
|
for serial_num in coordinator.data
|
||||||
|
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AmazonSensorEntity(AmazonEntity, SensorEntity):
|
||||||
|
"""Sensor device."""
|
||||||
|
|
||||||
|
entity_description: AmazonSensorEntityDescription
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_unit_of_measurement(self) -> str | None:
|
||||||
|
"""Return the unit of measurement of the sensor."""
|
||||||
|
if self.entity_description.native_unit_of_measurement_fn:
|
||||||
|
return self.entity_description.native_unit_of_measurement_fn(
|
||||||
|
self.device, self.entity_description.key
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().native_unit_of_measurement
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> StateType:
|
||||||
|
"""Return the state of the sensor."""
|
||||||
|
return self.device.sensors[self.entity_description.key].value
|
95
homeassistant/components/alexa_devices/strings.json
Normal file
95
homeassistant/components/alexa_devices/strings.json
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"data_code": "One-time password (OTP code)",
|
||||||
|
"data_description_country": "The country where your Amazon account is registered.",
|
||||||
|
"data_description_username": "The email address 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."
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"flow_title": "{username}",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"country": "[%key:common::config_flow::data::country%]",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
|
"code": "[%key:component::alexa_devices::common::data_code%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"country": "[%key:component::alexa_devices::common::data_description_country%]",
|
||||||
|
"username": "[%key:component::alexa_devices::common::data_description_username%]",
|
||||||
|
"password": "[%key:component::alexa_devices::common::data_description_password%]",
|
||||||
|
"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": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"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%]"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"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%]",
|
||||||
|
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"binary_sensor": {
|
||||||
|
"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": {
|
||||||
|
"speak": {
|
||||||
|
"name": "Speak"
|
||||||
|
},
|
||||||
|
"announce": {
|
||||||
|
"name": "Announce"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"switch": {
|
||||||
|
"do_not_disturb": {
|
||||||
|
"name": "Do not disturb"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"cannot_connect_with_error": {
|
||||||
|
"message": "Error connecting: {error}"
|
||||||
|
},
|
||||||
|
"cannot_retrieve_data_with_error": {
|
||||||
|
"message": "Error retrieving data: {error}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
86
homeassistant/components/alexa_devices/switch.py
Normal file
86
homeassistant/components/alexa_devices/switch.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"""Support for switches."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any, Final
|
||||||
|
|
||||||
|
from aioamazondevices.api import AmazonDevice
|
||||||
|
|
||||||
|
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from .coordinator import AmazonConfigEntry
|
||||||
|
from .entity import AmazonEntity
|
||||||
|
from .utils import alexa_api_call
|
||||||
|
|
||||||
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class AmazonSwitchEntityDescription(SwitchEntityDescription):
|
||||||
|
"""Alexa Devices switch entity description."""
|
||||||
|
|
||||||
|
is_on_fn: Callable[[AmazonDevice], bool]
|
||||||
|
subkey: str
|
||||||
|
method: str
|
||||||
|
|
||||||
|
|
||||||
|
SWITCHES: Final = (
|
||||||
|
AmazonSwitchEntityDescription(
|
||||||
|
key="do_not_disturb",
|
||||||
|
subkey="AUDIO_PLAYER",
|
||||||
|
translation_key="do_not_disturb",
|
||||||
|
is_on_fn=lambda _device: _device.do_not_disturb,
|
||||||
|
method="set_do_not_disturb",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AmazonConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Alexa Devices switches based on a config entry."""
|
||||||
|
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
|
||||||
|
for switch_desc in SWITCHES
|
||||||
|
for serial_num in coordinator.data
|
||||||
|
if switch_desc.subkey in coordinator.data[serial_num].capabilities
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||||
|
"""Switch device."""
|
||||||
|
|
||||||
|
entity_description: AmazonSwitchEntityDescription
|
||||||
|
|
||||||
|
@alexa_api_call
|
||||||
|
async def _switch_set_state(self, state: bool) -> None:
|
||||||
|
"""Set desired switch state."""
|
||||||
|
method = getattr(self.coordinator.api, self.entity_description.method)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert method is not None
|
||||||
|
|
||||||
|
await method(self.device, state)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn the switch on."""
|
||||||
|
await self._switch_set_state(True)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn the switch off."""
|
||||||
|
await self._switch_set_state(False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return True if switch is on."""
|
||||||
|
return self.entity_description.is_on_fn(self.device)
|
40
homeassistant/components/alexa_devices/utils.py
Normal file
40
homeassistant/components/alexa_devices/utils.py
Normal 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
|
27
homeassistant/components/altruist/__init__.py
Normal file
27
homeassistant/components/altruist/__init__.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"""The Altruist integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .coordinator import AltruistConfigEntry, AltruistDataUpdateCoordinator
|
||||||
|
|
||||||
|
PLATFORMS = [Platform.SENSOR]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool:
|
||||||
|
"""Set up Altruist from a config entry."""
|
||||||
|
|
||||||
|
coordinator = AltruistDataUpdateCoordinator(hass, entry)
|
||||||
|
|
||||||
|
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: AltruistConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
107
homeassistant/components/altruist/config_flow.py
Normal file
107
homeassistant/components/altruist/config_flow.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""Config flow for the Altruist integration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
|
from .const import CONF_HOST, DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AltruistConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Altruist."""
|
||||||
|
|
||||||
|
device: AltruistDeviceModel
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
ip_address = ""
|
||||||
|
if user_input is not None:
|
||||||
|
ip_address = user_input[CONF_HOST]
|
||||||
|
try:
|
||||||
|
client = await AltruistClient.from_ip_address(
|
||||||
|
async_get_clientsession(self.hass), ip_address
|
||||||
|
)
|
||||||
|
except AltruistError:
|
||||||
|
errors["base"] = "no_device_found"
|
||||||
|
else:
|
||||||
|
self.device = client.device
|
||||||
|
await self.async_set_unique_id(
|
||||||
|
client.device_id, raise_on_progress=False
|
||||||
|
)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=self.device.id,
|
||||||
|
data={
|
||||||
|
CONF_HOST: ip_address,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
data_schema = self.add_suggested_values_to_schema(
|
||||||
|
vol.Schema({vol.Required(CONF_HOST): str}),
|
||||||
|
{CONF_HOST: ip_address},
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={
|
||||||
|
"ip_address": ip_address,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_zeroconf(
|
||||||
|
self, discovery_info: ZeroconfServiceInfo
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle zeroconf discovery."""
|
||||||
|
_LOGGER.debug("Zeroconf discovery: %s", discovery_info)
|
||||||
|
try:
|
||||||
|
client = await AltruistClient.from_ip_address(
|
||||||
|
async_get_clientsession(self.hass), str(discovery_info.ip_address)
|
||||||
|
)
|
||||||
|
except AltruistError:
|
||||||
|
return self.async_abort(reason="no_device_found")
|
||||||
|
|
||||||
|
self.device = client.device
|
||||||
|
_LOGGER.debug("Zeroconf device: %s", client.device)
|
||||||
|
await self.async_set_unique_id(client.device_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
self.context.update(
|
||||||
|
{
|
||||||
|
"title_placeholders": {
|
||||||
|
"name": self.device.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return await self.async_step_discovery_confirm()
|
||||||
|
|
||||||
|
async def async_step_discovery_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Confirm discovery."""
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=self.device.id,
|
||||||
|
data={
|
||||||
|
CONF_HOST: self.device.ip_address,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._set_confirm_only()
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="discovery_confirm",
|
||||||
|
description_placeholders={
|
||||||
|
"model": self.device.id,
|
||||||
|
},
|
||||||
|
)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user