Compare commits

..

4 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
765d7699f3 Tibber, handle failed update
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-03-25 11:18:37 +01:00
Daniel Hjelseth Høyer
98d636274a Tibber, handle failed update
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-03-24 08:29:28 +01:00
Daniel Hjelseth Høyer
56423f5712 Tibber, handle failed update
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-03-24 06:35:58 +01:00
Daniel Hjelseth Høyer
e55f89a800 Tibber, handle failed update
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-03-22 08:25:37 +01:00
1650 changed files with 14022 additions and 72111 deletions

View File

@@ -1,228 +0,0 @@
---
name: raise-pull-request
description: |
Use this agent when creating a pull request for the Home Assistant core repository after completing implementation work. This agent automates the PR creation process including running tests, formatting checks, and proper checkbox handling.
model: inherit
color: green
tools: Read, Bash, Grep, Glob
---
You are an expert at creating pull requests for the Home Assistant core repository. You will automate the PR creation process with proper verification, formatting, testing, and checkbox handling.
**Execute each step in order. Do not skip steps.**
## Step 1: Gather Information
Run these commands in parallel to analyze the changes:
```bash
# Get current branch and remote
git branch --show-current
git remote -v | grep push
# Determine the best available dev reference
if git rev-parse --verify --quiet upstream/dev >/dev/null; then
BASE_REF="upstream/dev"
elif git rev-parse --verify --quiet origin/dev >/dev/null; then
BASE_REF="origin/dev"
elif git rev-parse --verify --quiet dev >/dev/null; then
BASE_REF="dev"
else
echo "Could not find upstream/dev, origin/dev, or local dev"
exit 1
fi
BASE_SHA="$(git merge-base "$BASE_REF" HEAD)"
echo "BASE_REF=$BASE_REF"
echo "BASE_SHA=$BASE_SHA"
# Get commit info for this branch vs dev
git log "${BASE_SHA}..HEAD" --oneline
# Check what files changed
git diff "${BASE_SHA}..HEAD" --name-only
# Check if test files were added/modified
git diff "${BASE_SHA}..HEAD" --name-only | grep -E "^tests/.*\.py$" || echo "NO_TESTS_CHANGED"
# Check if manifest.json changed
git diff "${BASE_SHA}..HEAD" --name-only | grep "manifest.json" || echo "NO_MANIFEST_CHANGED"
```
From the file paths, extract the **integration domain** from `homeassistant/components/{integration}/` or `tests/components/{integration}/`.
**Track results:**
- `BASE_REF`: the dev reference used for comparison
- `BASE_SHA`: the merge-base commit used for diff-based checks
- `TESTS_CHANGED`: true if test files were added or modified
- `MANIFEST_CHANGED`: true if manifest.json was modified
**If no suitable dev reference is available, STOP and tell the user to fetch `upstream/dev`, `origin/dev`, or a local `dev` branch before continuing.**
## Step 2: Run Code Quality Checks
Run `prek` to perform code quality checks (formatting, linting, hassfest, etc.) on the files changed since `BASE_SHA`:
```bash
prek run --from-ref "$BASE_SHA" --to-ref HEAD
```
**Track results:**
- `PREK_PASSED`: true if `prek run` exits with code 0
**If `prek` fails or is not available, STOP and report the failure to the user. Do not proceed with PR creation. If the failure appears to be an environment setup issue (e.g., missing tools, command not found, venv not activated), also point the user to https://developers.home-assistant.io/docs/development_environment.**
## Step 3: Stage Any Changes from Checks
If `prek` made any formatting or generated file changes, stage and commit them as a separate commit:
```bash
git status --porcelain
# If changes exist:
git add -A
git commit -m "Apply prek formatting and generated file updates"
```
## Step 4: Run Tests
Run pytest for the specific integration:
```bash
pytest tests/components/{integration} \
--timeout=60 \
--durations-min=1 \
--durations=0 \
-q
```
**Track results:**
- `TESTS_PASSED`: true if pytest exits with code 0
**If tests fail, STOP and report the failures to the user. Do not proceed with PR creation.**
## Step 5: Identify PR Metadata
Write a release-note-style PR title summarizing the change. The title becomes the release notes entry, so it should be a complete sentence fragment describing what changed in imperative mood.
**PR Title Examples by Type:**
| Type | Example titles |
|------|----------------|
| Bugfix | `Fix Hikvision NVR binary sensors not being detected` |
| | `Fix JSON serialization of time objects in anthropic tool results` |
| | `Fix config flow bug in Tesla Fleet` |
| Dependency | `Bump eheimdigital to 1.5.0` |
| | `Bump python-otbr-api to 2.7.1` |
| New feature | `Add asyncio-level timeout to Backblaze B2 uploads` |
| | `Add Nettleie optimization option` |
| Code quality | `Add exception translations to Teslemetry` |
| | `Improve test coverage of Tesla Fleet` |
| | `Refactor adguard tests to use proper fixtures for mocking` |
| | `Simplify entity init in Proxmox` |
## Step 6: Verify Development Checklist
Check each item from the [development checklist](https://developers.home-assistant.io/docs/development_checklist/):
| Item | How to verify |
|------|---------------|
| External libraries on PyPI | Check manifest.json requirements - all should be PyPI packages |
| Dependencies in requirements_all.txt | Only if dependency declarations changed (the `requirements` field in `manifest.json` or `requirements_all.txt`), run `python -m script.gen_requirements_all` |
| Codeowners updated | If this is a new integration, ensure its `manifest.json` includes a `codeowners` field with one or more GitHub usernames |
| No commented out code | Visually scan the diff for blocks of commented-out code |
**Track results:**
- `NO_COMMENTED_CODE`: true if no blocks of commented-out code found in the diff
- `DEPENDENCIES_CHANGED`: true if the diff changes the `requirements` field in `manifest.json` or changes `requirements_all.txt`
- `REQUIREMENTS_UPDATED`: true if `DEPENDENCIES_CHANGED` is true and requirements_all.txt was regenerated successfully; not applicable if `DEPENDENCIES_CHANGED` is false
- `CHECKLIST_PASSED`: true if all items above pass
## Step 7: Determine Type of Change
Select exactly ONE based on the changes. Mark the selected type with `[x]` and all others with `[ ]` (space):
| Type | Condition |
|------|-----------|
| Dependency upgrade | Only manifest.json/requirements changes |
| Bugfix | Fixes broken behavior, no new features |
| New integration | New folder in components/ |
| New feature | Adds capability to existing integration |
| Deprecation | Adds deprecation warnings for future breaking change |
| Breaking change | Removes or changes existing functionality |
| Code quality | Only refactoring or test additions, no functional change |
**Track results:**
- `CHANGE_TYPE`: the selected type (e.g., "Bugfix", "New feature", "Code quality", etc.)
**Important:** All seven type options must remain in the PR body. Only the selected type gets `[x]`, all others get `[ ]`.
## Step 8: Determine Checkbox States
Based on the verification steps above, determine checkbox states:
| Checkbox | Condition to tick |
|----------|-------------------|
| The code change is tested and works locally | Leave unchecked for the contributor to verify manually (this refers to manual testing, not unit tests) |
| Local tests pass | Tick only if `TESTS_PASSED` is true |
| I understand the code I am submitting and can explain how it works | Leave unchecked for the contributor to review and set manually |
| There is no commented out code | Tick only if `NO_COMMENTED_CODE` is true |
| Development checklist | Tick only if `CHECKLIST_PASSED` is true |
| Perfect PR recommendations | Tick only if the PR affects a single integration or closely related modules, represents one primary type of change, and has a clear, self-contained scope |
| Formatted using Ruff | Tick only if `PREK_PASSED` is true |
| Tests have been added | Tick only if `TESTS_CHANGED` is true AND the changes exercise new or changed functionality (not only cosmetic test changes) |
| Documentation added/updated | Tick if documentation PR created (or not applicable) |
| Manifest file fields filled out | Tick if `PREK_PASSED` is true (or not applicable) |
| Dependencies in requirements_all.txt | Tick only if `DEPENDENCIES_CHANGED` is false, or if `DEPENDENCIES_CHANGED` is true and `REQUIREMENTS_UPDATED` is true |
| Dependency changelog linked | Tick if dependency changelog linked in PR description (or not applicable) |
| Any generated code has been carefully reviewed | Leave unchecked for the contributor to review and set manually |
## Step 9: Breaking Change Section
**If `CHANGE_TYPE` is NOT "Breaking change" or "Deprecation": REMOVE the entire "## Breaking change" section from the PR body (including the heading).**
If `CHANGE_TYPE` IS "Breaking change" or "Deprecation", keep the `## Breaking change` section and describe:
- What breaks
- How users can fix it
- Why it was necessary
## Step 10: Push Branch and Create PR
```bash
# Get branch name and GitHub username
BRANCH=$(git branch --show-current)
PUSH_REMOTE=$(git config "branch.$BRANCH.remote" 2>/dev/null || git remote | head -1)
GITHUB_USER=$(gh api user --jq .login 2>/dev/null || git remote get-url "$PUSH_REMOTE" | sed -E 's#.*[:/]([^/]+)/([^/]+)(\.git)?$#\1#')
# Create PR (gh pr create pushes the branch automatically)
gh pr create --repo home-assistant/core --base dev \
--head "$GITHUB_USER:$BRANCH" \
--title "TITLE_HERE" \
--body "$(cat <<'EOF'
BODY_HERE
EOF
)"
```
### PR Body Template
Read the PR template from `.github/PULL_REQUEST_TEMPLATE.md` and use it as the basis for the PR body. **Do not hardcode the template — always read it from the file to stay in sync with upstream changes.**
Use any HTML comments (`<!-- ... -->`) in the template as guidance to understand what to fill in. For the final PR body sent to GitHub, keep the template text intact — do not delete any text from the template unless it explicitly instructs removal (e.g., the breaking change section when not applicable). Then fill in the sections:
1. **Breaking change section**: If the type is NOT "Breaking change" or "Deprecation", remove the entire `## Breaking change` section (heading and body). Otherwise, describe what breaks, how users can fix it, and why.
2. **Proposed change section**: Fill in a description of the change extracted from commit messages.
3. **Type of change**: Check exactly ONE checkbox matching the determined type from Step 7. Leave all others unchecked.
4. **Additional information**: Fill in any related issue numbers if known.
5. **Checklist**: Check boxes based on the conditions in Step 8. Leave manual-verification boxes unchecked for the contributor.
**Important:** Preserve all template structure, options, and link references exactly as they appear in the file — only modify checkbox states and fill in content sections.
## Step 11: Report Result
Provide the user with:
1. **PR URL** - The created pull request link
2. **Verification Summary** - Which checks passed/failed
3. **Unchecked Items** - List any checkboxes left unchecked and why
4. **User Action Required** - Remind user to:
- Review and set manual-verification checkboxes ("I understand the code..." and "Any generated code...") as applicable
- Consider reviewing two other open PRs
- Add any related issue numbers if applicable

View File

@@ -5,6 +5,14 @@ description: Review a GitHub pull request and provide feedback comments. Use whe
# Review GitHub Pull Request
## Preparation:
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
- Do NOT attempt any workarounds.
- Do NOT proceed with the review.
- ALERT about the failure and WAIT for instructions.
- This is a hard requirement - no exceptions.
## Follow these steps:
1. Use 'gh pr view' to get the PR details and description.
2. Use 'gh pr diff' to see all the changes in the PR.

View File

@@ -1,12 +1,6 @@
<!-- Automatically generated by gen_copilot_instructions.py, do not edit -->
# Copilot code review instructions
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not add comments about code style, formatting or linting issues.
# GitHub Copilot & Claude Code Instructions
This repository contains the core of Home Assistant, a Python 3 based home automation application.

View File

@@ -112,7 +112,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -123,7 +123,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -224,6 +224,7 @@ jobs:
matrix:
machine:
- generic-x86-64
- intel-nuc
- khadas-vim3
- odroid-c2
- odroid-c4
@@ -247,6 +248,10 @@ jobs:
- machine: qemux86-64
arch: amd64
runs-on: ubuntu-24.04
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
- machine: intel-nuc
arch: amd64
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 3
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.5"
HA_SHORT_VERSION: "2026.4"
ADDITIONAL_PYTHON_VERSIONS: "[]"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
@@ -120,7 +120,7 @@ jobs:
run: |
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
- name: Filter for core changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: core
with:
filters: .core_files.yaml
@@ -135,7 +135,7 @@ jobs:
echo "Result:"
cat .integration_paths.yaml
- name: Filter for integration changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: integrations
with:
filters: .integration_paths.yaml
@@ -280,7 +280,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -301,7 +301,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
with:
extra-args: --all-files zizmor
@@ -364,7 +364,7 @@ jobs:
echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
key: >-
@@ -372,7 +372,7 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -384,7 +384,7 @@ jobs:
env.HA_SHORT_VERSION }}-
- name: Check if apt cache exists
id: cache-apt-check
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: |
@@ -430,7 +430,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -484,7 +484,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -515,7 +515,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -552,7 +552,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -643,7 +643,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -694,7 +694,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -747,7 +747,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -804,7 +804,7 @@ jobs:
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -812,7 +812,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: .mypy_cache
key: >-
@@ -854,7 +854,7 @@ jobs:
- base
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -887,7 +887,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -930,7 +930,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -964,7 +964,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -1080,7 +1080,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1115,7 +1115,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -1238,7 +1238,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1275,7 +1275,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -1392,7 +1392,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
fail_ci_if_error: true
flags: full-suite
@@ -1421,7 +1421,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1455,7 +1455,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
fail-on-cache-miss: true
@@ -1563,7 +1563,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1591,7 +1591,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
report_type: test_results
fail_ci_if_error: true

View File

@@ -174,6 +174,7 @@ homeassistant.components.dnsip.*
homeassistant.components.doorbird.*
homeassistant.components.dormakaba_dkey.*
homeassistant.components.downloader.*
homeassistant.components.dropbox.*
homeassistant.components.droplet.*
homeassistant.components.dsmr.*
homeassistant.components.duckdns.*
@@ -273,12 +274,10 @@ homeassistant.components.homekit_controller.storage
homeassistant.components.homekit_controller.utils
homeassistant.components.homewizard.*
homeassistant.components.homeworks.*
homeassistant.components.hr_energy_qube.*
homeassistant.components.http.*
homeassistant.components.huawei_lte.*
homeassistant.components.humidifier.*
homeassistant.components.husqvarna_automower.*
homeassistant.components.huum.*
homeassistant.components.hydrawise.*
homeassistant.components.hyperion.*
homeassistant.components.hypontech.*
@@ -328,7 +327,6 @@ homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
homeassistant.components.lektrico.*
homeassistant.components.letpot.*
homeassistant.components.lg_infrared.*
homeassistant.components.libre_hardware_monitor.*
homeassistant.components.lidarr.*
homeassistant.components.lifx.*
@@ -578,7 +576,6 @@ homeassistant.components.trmnl.*
homeassistant.components.tts.*
homeassistant.components.twentemilieu.*
homeassistant.components.unifi.*
homeassistant.components.unifi_access.*
homeassistant.components.unifiprotect.*
homeassistant.components.upcloud.*
homeassistant.components.update.*

36
CODEOWNERS generated
View File

@@ -214,16 +214,14 @@ build.json @home-assistant/supervisor
/tests/components/balboa/ @garbled1 @natekspencer
/homeassistant/components/bang_olufsen/ @mj23000
/tests/components/bang_olufsen/ @mj23000
/homeassistant/components/battery/ @home-assistant/core
/tests/components/battery/ @home-assistant/core
/homeassistant/components/bayesian/ @HarvsG
/tests/components/bayesian/ @HarvsG
/homeassistant/components/beewi_smartclim/ @alemuro
/homeassistant/components/binary_sensor/ @home-assistant/core
/tests/components/binary_sensor/ @home-assistant/core
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
/homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
/homeassistant/components/blebox/ @bbx-a @swistakm
/tests/components/blebox/ @bbx-a @swistakm
/homeassistant/components/blink/ @fronzbot
/tests/components/blink/ @fronzbot
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
@@ -401,6 +399,8 @@ build.json @home-assistant/supervisor
/tests/components/dremel_3d_printer/ @tkdrob
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
/homeassistant/components/dropbox/ @bdr99
/tests/components/dropbox/ @bdr99
/homeassistant/components/droplet/ @sarahseidman
/tests/components/droplet/ @sarahseidman
/homeassistant/components/dsmr/ @Robbie1221
@@ -737,10 +737,8 @@ build.json @home-assistant/supervisor
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
/tests/components/html5/ @alexyao2015 @tr4nt0r
/homeassistant/components/html5/ @alexyao2015
/tests/components/html5/ @alexyao2015
/homeassistant/components/http/ @home-assistant/core
/tests/components/http/ @home-assistant/core
/homeassistant/components/huawei_lte/ @scop @fphammerle
@@ -786,8 +784,6 @@ build.json @home-assistant/supervisor
/tests/components/igloohome/ @keithle888
/homeassistant/components/ign_sismologia/ @exxamalte
/tests/components/ign_sismologia/ @exxamalte
/homeassistant/components/illuminance/ @home-assistant/core
/tests/components/illuminance/ @home-assistant/core
/homeassistant/components/image/ @home-assistant/core
/tests/components/image/ @home-assistant/core
/homeassistant/components/image_processing/ @home-assistant/core
@@ -947,8 +943,6 @@ build.json @home-assistant/supervisor
/tests/components/lektrico/ @lektrico
/homeassistant/components/letpot/ @jpelgrom
/tests/components/letpot/ @jpelgrom
/homeassistant/components/lg_infrared/ @home-assistant/core
/tests/components/lg_infrared/ @home-assistant/core
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
/tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
@@ -1077,8 +1071,6 @@ build.json @home-assistant/supervisor
/tests/components/modern_forms/ @wonderslug
/homeassistant/components/moehlenhoff_alpha2/ @j-a-n
/tests/components/moehlenhoff_alpha2/ @j-a-n
/homeassistant/components/moisture/ @home-assistant/core
/tests/components/moisture/ @home-assistant/core
/homeassistant/components/monarch_money/ @jeeftor
/tests/components/monarch_money/ @jeeftor
/homeassistant/components/monoprice/ @etsinko @OnFreund
@@ -1226,12 +1218,12 @@ build.json @home-assistant/supervisor
/tests/components/onewire/ @garbled1 @epenet
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
/tests/components/onkyo/ @arturpragacz @eclair4151
/homeassistant/components/onvif/ @jterrace
/tests/components/onvif/ @jterrace
/homeassistant/components/onvif/ @hunterjm @jterrace
/tests/components/onvif/ @hunterjm @jterrace
/homeassistant/components/open_meteo/ @frenck
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek @ab3lson
/tests/components/open_router/ @joostlek @ab3lson
/homeassistant/components/open_router/ @joostlek
/tests/components/open_router/ @joostlek
/homeassistant/components/opendisplay/ @g4bri3lDev
/tests/components/opendisplay/ @g4bri3lDev
/homeassistant/components/openerz/ @misialq
@@ -1317,8 +1309,6 @@ build.json @home-assistant/supervisor
/tests/components/poolsense/ @haemishkyd
/homeassistant/components/portainer/ @erwindouna
/tests/components/portainer/ @erwindouna
/homeassistant/components/power/ @home-assistant/core
/tests/components/power/ @home-assistant/core
/homeassistant/components/powerfox/ @klaasnicolaas
/tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerfox_local/ @klaasnicolaas
@@ -1604,8 +1594,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli
/homeassistant/components/solarman/ @solarmanpv
/tests/components/solarman/ @solarmanpv
/homeassistant/components/solax/ @squishykid @Darsstar
/tests/components/solax/ @squishykid @Darsstar
/homeassistant/components/soma/ @ratsept
@@ -1715,8 +1703,6 @@ build.json @home-assistant/supervisor
/tests/components/tellduslive/ @fredrike
/homeassistant/components/teltonika/ @karlbeecken
/tests/components/teltonika/ @karlbeecken
/homeassistant/components/temperature/ @home-assistant/core
/tests/components/temperature/ @home-assistant/core
/homeassistant/components/template/ @Petro31 @home-assistant/core
/tests/components/template/ @Petro31 @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
@@ -1762,8 +1748,6 @@ build.json @home-assistant/supervisor
/tests/components/tomorrowio/ @raman325 @lymanepp
/homeassistant/components/totalconnect/ @austinmroczek
/tests/components/totalconnect/ @austinmroczek
/homeassistant/components/touchline/ @mnordseth
/tests/components/touchline/ @mnordseth
/homeassistant/components/touchline_sl/ @jnsgruk
/tests/components/touchline_sl/ @jnsgruk
/homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696

2
Dockerfile generated
View File

@@ -29,7 +29,7 @@ RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv
&& pip3 install uv==0.11.1
&& pip3 install uv==0.10.6
WORKDIR /usr/src

View File

@@ -238,23 +238,15 @@ DEFAULT_INTEGRATIONS = {
"timer",
#
# Base platforms:
# Note: Calendar and todo are not included to prevent them from registering
# their frontend panels when there are no calendar or todo integrations.
*(BASE_PLATFORMS - {"calendar", "todo"}),
*BASE_PLATFORMS,
#
# Integrations providing triggers and conditions for base platforms:
"air_quality",
"battery",
"door",
"garage_door",
"gate",
"humidity",
"illuminance",
"moisture",
"motion",
"occupancy",
"power",
"temperature",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
@@ -470,7 +462,6 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
translation.async_setup(hass)
recovery = hass.config.recovery_mode
device_registry.async_get(hass)
try:
await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()),

View File

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

View File

@@ -1,11 +1,5 @@
{
"domain": "lg",
"name": "LG",
"integrations": [
"lg_infrared",
"lg_netcast",
"lg_soundbar",
"lg_thinq",
"webostv"
]
"integrations": ["lg_netcast", "lg_soundbar", "lg_thinq", "webostv"]
}

View File

@@ -9,6 +9,6 @@
},
"iot_class": "cloud_push",
"loggers": ["jaraco.abode", "lomond"],
"requirements": ["jaraco.abode==6.4.0"],
"requirements": ["jaraco.abode==6.2.1"],
"single_config_entry": true
}

View File

@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
@@ -41,7 +40,6 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
AbodeSensorDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[
device.temp_unit
],
@@ -50,14 +48,12 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
AbodeSensorDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda _: PERCENTAGE,
value_fn=lambda device: cast(float, device.humidity),
),
AbodeSensorDescription(
key="lux",
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda _: LIGHT_LUX,
value_fn=lambda device: cast(float, device.lux),
),

View File

@@ -1 +1 @@
"""The Actiontec integration."""
"""The actiontec component."""

View File

@@ -1,134 +0,0 @@
"""Provides conditions for air quality."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
make_entity_numerical_condition,
make_entity_numerical_condition_with_unit,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import (
CarbonMonoxideConcentrationConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
NitrogenMonoxideConcentrationConverter,
OzoneConcentrationConverter,
SulphurDioxideConcentrationConverter,
UnitlessRatioConverter,
)
def _make_detected_condition(
device_class: BinarySensorDeviceClass,
) -> type[Condition]:
"""Create a detected condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
)
def _make_cleared_condition(
device_class: BinarySensorDeviceClass,
) -> type[Condition]:
"""Create a cleared condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
)
CONDITIONS: dict[str, type[Condition]] = {
# Binary sensor conditions (detected/cleared)
"is_gas_detected": _make_detected_condition(BinarySensorDeviceClass.GAS),
"is_gas_cleared": _make_cleared_condition(BinarySensorDeviceClass.GAS),
"is_co_detected": _make_detected_condition(BinarySensorDeviceClass.CO),
"is_co_cleared": _make_cleared_condition(BinarySensorDeviceClass.CO),
"is_smoke_detected": _make_detected_condition(BinarySensorDeviceClass.SMOKE),
"is_smoke_cleared": _make_cleared_condition(BinarySensorDeviceClass.SMOKE),
# Numerical sensor conditions with unit conversion
"is_co_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"is_ozone_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"is_voc_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"is_voc_ratio_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"is_no_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"is_no2_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"is_so2_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor conditions without unit conversion (single-unit device classes)
"is_co2_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"is_pm1_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm25_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm4_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm10_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_n2o_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the air quality conditions."""
return CONDITIONS

View File

@@ -1,449 +0,0 @@
# --- Common condition fields ---
.condition_behavior: &condition_behavior
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
# --- Unit lists for multi-unit pollutants ---
.co_units: &co_units
- "ppb"
- "ppm"
- "mg/m³"
- "μg/m³"
.ozone_units: &ozone_units
- "ppb"
- "ppm"
- "μg/m³"
.voc_units: &voc_units
- "μg/m³"
- "mg/m³"
.voc_ratio_units: &voc_ratio_units
- "ppb"
- "ppm"
.no_units: &no_units
- "ppb"
- "μg/m³"
.no2_units: &no2_units
- "ppb"
- "ppm"
- "μg/m³"
.so2_units: &so2_units
- "ppb"
- "μg/m³"
# --- Entity filter anchors ---
.co_threshold_entity: &co_threshold_entity
- domain: input_number
unit_of_measurement: *co_units
- domain: sensor
device_class: carbon_monoxide
- domain: number
device_class: carbon_monoxide
.co2_threshold_entity: &co2_threshold_entity
- domain: input_number
unit_of_measurement: "ppm"
- domain: sensor
device_class: carbon_dioxide
- domain: number
device_class: carbon_dioxide
.pm1_threshold_entity: &pm1_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm1
- domain: number
device_class: pm1
.pm25_threshold_entity: &pm25_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm25
- domain: number
device_class: pm25
.pm4_threshold_entity: &pm4_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm4
- domain: number
device_class: pm4
.pm10_threshold_entity: &pm10_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm10
- domain: number
device_class: pm10
.ozone_threshold_entity: &ozone_threshold_entity
- domain: input_number
unit_of_measurement: *ozone_units
- domain: sensor
device_class: ozone
- domain: number
device_class: ozone
.voc_threshold_entity: &voc_threshold_entity
- domain: input_number
unit_of_measurement: *voc_units
- domain: sensor
device_class: volatile_organic_compounds
- domain: number
device_class: volatile_organic_compounds
.voc_ratio_threshold_entity: &voc_ratio_threshold_entity
- domain: input_number
unit_of_measurement: *voc_ratio_units
- domain: sensor
device_class: volatile_organic_compounds_parts
- domain: number
device_class: volatile_organic_compounds_parts
.no_threshold_entity: &no_threshold_entity
- domain: input_number
unit_of_measurement: *no_units
- domain: sensor
device_class: nitrogen_monoxide
- domain: number
device_class: nitrogen_monoxide
.no2_threshold_entity: &no2_threshold_entity
- domain: input_number
unit_of_measurement: *no2_units
- domain: sensor
device_class: nitrogen_dioxide
- domain: number
device_class: nitrogen_dioxide
.n2o_threshold_entity: &n2o_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: nitrous_oxide
- domain: number
device_class: nitrous_oxide
.so2_threshold_entity: &so2_threshold_entity
- domain: input_number
unit_of_measurement: *so2_units
- domain: sensor
device_class: sulphur_dioxide
- domain: number
device_class: sulphur_dioxide
# --- Number anchors for single-unit pollutants ---
.co2_threshold_number: &co2_threshold_number
mode: box
unit_of_measurement: "ppm"
.ugm3_threshold_number: &ugm3_threshold_number
mode: box
unit_of_measurement: "μg/m³"
# --- Binary sensor targets ---
.target_gas: &target_gas
entity:
- domain: binary_sensor
device_class: gas
.target_co_binary: &target_co_binary
entity:
- domain: binary_sensor
device_class: carbon_monoxide
.target_smoke: &target_smoke
entity:
- domain: binary_sensor
device_class: smoke
# --- Sensor targets ---
.target_co_sensor: &target_co_sensor
entity:
- domain: sensor
device_class: carbon_monoxide
.target_co2: &target_co2
entity:
- domain: sensor
device_class: carbon_dioxide
.target_pm1: &target_pm1
entity:
- domain: sensor
device_class: pm1
.target_pm25: &target_pm25
entity:
- domain: sensor
device_class: pm25
.target_pm4: &target_pm4
entity:
- domain: sensor
device_class: pm4
.target_pm10: &target_pm10
entity:
- domain: sensor
device_class: pm10
.target_ozone: &target_ozone
entity:
- domain: sensor
device_class: ozone
.target_voc: &target_voc
entity:
- domain: sensor
device_class: volatile_organic_compounds
.target_voc_ratio: &target_voc_ratio
entity:
- domain: sensor
device_class: volatile_organic_compounds_parts
.target_no: &target_no
entity:
- domain: sensor
device_class: nitrogen_monoxide
.target_no2: &target_no2
entity:
- domain: sensor
device_class: nitrogen_dioxide
.target_n2o: &target_n2o
entity:
- domain: sensor
device_class: nitrous_oxide
.target_so2: &target_so2
entity:
- domain: sensor
device_class: sulphur_dioxide
# --- Binary sensor conditions ---
.condition_binary_common: &condition_binary_common
fields:
behavior: *condition_behavior
is_gas_detected:
<<: *condition_binary_common
target: *target_gas
is_gas_cleared:
<<: *condition_binary_common
target: *target_gas
is_co_detected:
<<: *condition_binary_common
target: *target_co_binary
is_co_cleared:
<<: *condition_binary_common
target: *target_co_binary
is_smoke_detected:
<<: *condition_binary_common
target: *target_smoke
is_smoke_cleared:
<<: *condition_binary_common
target: *target_smoke
# --- Numerical sensor conditions with unit conversion ---
is_co_value:
target: *target_co_sensor
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *co_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *co_units
is_ozone_value:
target: *target_ozone
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *ozone_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *ozone_units
is_voc_value:
target: *target_voc
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *voc_units
is_voc_ratio_value:
target: *target_voc_ratio
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_ratio_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *voc_ratio_units
is_no_value:
target: *target_no
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *no_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *no_units
is_no2_value:
target: *target_no2
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *no2_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *no2_units
is_so2_value:
target: *target_so2
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *so2_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *so2_units
# --- Numerical sensor conditions without unit conversion ---
is_co2_value:
target: *target_co2
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *co2_threshold_entity
mode: is
number: *co2_threshold_number
is_pm1_value:
target: *target_pm1
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm1_threshold_entity
mode: is
number: *ugm3_threshold_number
is_pm25_value:
target: *target_pm25
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm25_threshold_entity
mode: is
number: *ugm3_threshold_number
is_pm4_value:
target: *target_pm4
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm4_threshold_entity
mode: is
number: *ugm3_threshold_number
is_pm10_value:
target: *target_pm10
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm10_threshold_entity
mode: is
number: *ugm3_threshold_number
is_n2o_value:
target: *target_n2o
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *n2o_threshold_entity
mode: is
number: *ugm3_threshold_number

View File

@@ -1,164 +1,7 @@
{
"conditions": {
"is_co2_value": {
"condition": "mdi:molecule-co2"
},
"is_co_cleared": {
"condition": "mdi:check-circle"
},
"is_co_detected": {
"condition": "mdi:molecule-co"
},
"is_co_value": {
"condition": "mdi:molecule-co"
},
"is_gas_cleared": {
"condition": "mdi:check-circle"
},
"is_gas_detected": {
"condition": "mdi:gas-cylinder"
},
"is_n2o_value": {
"condition": "mdi:factory"
},
"is_no2_value": {
"condition": "mdi:factory"
},
"is_no_value": {
"condition": "mdi:factory"
},
"is_ozone_value": {
"condition": "mdi:weather-sunny-alert"
},
"is_pm10_value": {
"condition": "mdi:blur"
},
"is_pm1_value": {
"condition": "mdi:blur"
},
"is_pm25_value": {
"condition": "mdi:blur"
},
"is_pm4_value": {
"condition": "mdi:blur"
},
"is_smoke_cleared": {
"condition": "mdi:check-circle"
},
"is_smoke_detected": {
"condition": "mdi:smoke-detector-variant"
},
"is_so2_value": {
"condition": "mdi:factory"
},
"is_voc_ratio_value": {
"condition": "mdi:air-filter"
},
"is_voc_value": {
"condition": "mdi:air-filter"
}
},
"entity_component": {
"_": {
"default": "mdi:air-filter"
}
},
"triggers": {
"co2_changed": {
"trigger": "mdi:molecule-co2"
},
"co2_crossed_threshold": {
"trigger": "mdi:molecule-co2"
},
"co_changed": {
"trigger": "mdi:molecule-co"
},
"co_cleared": {
"trigger": "mdi:check-circle"
},
"co_crossed_threshold": {
"trigger": "mdi:molecule-co"
},
"co_detected": {
"trigger": "mdi:molecule-co"
},
"gas_cleared": {
"trigger": "mdi:check-circle"
},
"gas_detected": {
"trigger": "mdi:gas-cylinder"
},
"n2o_changed": {
"trigger": "mdi:factory"
},
"n2o_crossed_threshold": {
"trigger": "mdi:factory"
},
"no2_changed": {
"trigger": "mdi:factory"
},
"no2_crossed_threshold": {
"trigger": "mdi:factory"
},
"no_changed": {
"trigger": "mdi:factory"
},
"no_crossed_threshold": {
"trigger": "mdi:factory"
},
"ozone_changed": {
"trigger": "mdi:weather-sunny-alert"
},
"ozone_crossed_threshold": {
"trigger": "mdi:weather-sunny-alert"
},
"pm10_changed": {
"trigger": "mdi:blur"
},
"pm10_crossed_threshold": {
"trigger": "mdi:blur"
},
"pm1_changed": {
"trigger": "mdi:blur"
},
"pm1_crossed_threshold": {
"trigger": "mdi:blur"
},
"pm25_changed": {
"trigger": "mdi:blur"
},
"pm25_crossed_threshold": {
"trigger": "mdi:blur"
},
"pm4_changed": {
"trigger": "mdi:blur"
},
"pm4_crossed_threshold": {
"trigger": "mdi:blur"
},
"smoke_cleared": {
"trigger": "mdi:check-circle"
},
"smoke_detected": {
"trigger": "mdi:smoke-detector-variant"
},
"so2_changed": {
"trigger": "mdi:factory"
},
"so2_crossed_threshold": {
"trigger": "mdi:factory"
},
"voc_changed": {
"trigger": "mdi:air-filter"
},
"voc_crossed_threshold": {
"trigger": "mdi:air-filter"
},
"voc_ratio_changed": {
"trigger": "mdi:air-filter"
},
"voc_ratio_crossed_threshold": {
"trigger": "mdi:air-filter"
}
}
}

View File

@@ -1,565 +0,0 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_threshold_name": "Threshold type"
},
"conditions": {
"is_co2_value": {
"description": "Tests the carbon dioxide level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Carbon dioxide value"
},
"is_co_cleared": {
"description": "Tests if one or more carbon monoxide sensors are cleared.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Carbon monoxide cleared"
},
"is_co_detected": {
"description": "Tests if one or more carbon monoxide sensors are detecting carbon monoxide.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Carbon monoxide detected"
},
"is_co_value": {
"description": "Tests the carbon monoxide level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Carbon monoxide value"
},
"is_gas_cleared": {
"description": "Tests if one or more gas sensors are cleared.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Gas cleared"
},
"is_gas_detected": {
"description": "Tests if one or more gas sensors are detecting gas.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Gas detected"
},
"is_n2o_value": {
"description": "Tests the nitrous oxide level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Nitrous oxide value"
},
"is_no2_value": {
"description": "Tests the nitrogen dioxide level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Nitrogen dioxide value"
},
"is_no_value": {
"description": "Tests the nitrogen monoxide level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Nitrogen monoxide value"
},
"is_ozone_value": {
"description": "Tests the ozone level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Ozone value"
},
"is_pm10_value": {
"description": "Tests the PM10 level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "PM10 value"
},
"is_pm1_value": {
"description": "Tests the PM1 level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "PM1 value"
},
"is_pm25_value": {
"description": "Tests the PM2.5 level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "PM2.5 value"
},
"is_pm4_value": {
"description": "Tests the PM4 level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "PM4 value"
},
"is_smoke_cleared": {
"description": "Tests if one or more smoke sensors are cleared.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Smoke cleared"
},
"is_smoke_detected": {
"description": "Tests if one or more smoke sensors are detecting smoke.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Smoke detected"
},
"is_so2_value": {
"description": "Tests the sulphur dioxide level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Sulphur dioxide value"
},
"is_voc_ratio_value": {
"description": "Tests the volatile organic compounds ratio of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Volatile organic compounds ratio value"
},
"is_voc_value": {
"description": "Tests the volatile organic compounds level of one or more entities.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Volatile organic compounds value"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Air Quality",
"triggers": {
"co2_changed": {
"description": "Triggers after one or more carbon dioxide levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Carbon dioxide level changed"
},
"co2_crossed_threshold": {
"description": "Triggers after one or more carbon dioxide levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Carbon dioxide level crossed threshold"
},
"co_changed": {
"description": "Triggers after one or more carbon monoxide levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Carbon monoxide level changed"
},
"co_cleared": {
"description": "Triggers after one or more carbon monoxide sensors stop detecting carbon monoxide.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Carbon monoxide cleared"
},
"co_crossed_threshold": {
"description": "Triggers after one or more carbon monoxide levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Carbon monoxide level crossed threshold"
},
"co_detected": {
"description": "Triggers after one or more carbon monoxide sensors start detecting carbon monoxide.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Carbon monoxide detected"
},
"gas_cleared": {
"description": "Triggers after one or more gas sensors stop detecting gas.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Gas cleared"
},
"gas_detected": {
"description": "Triggers after one or more gas sensors start detecting gas.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Gas detected"
},
"n2o_changed": {
"description": "Triggers after one or more nitrous oxide levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrous oxide level changed"
},
"n2o_crossed_threshold": {
"description": "Triggers after one or more nitrous oxide levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrous oxide level crossed threshold"
},
"no2_changed": {
"description": "Triggers after one or more nitrogen dioxide levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrogen dioxide level changed"
},
"no2_crossed_threshold": {
"description": "Triggers after one or more nitrogen dioxide levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrogen dioxide level crossed threshold"
},
"no_changed": {
"description": "Triggers after one or more nitrogen monoxide levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrogen monoxide level changed"
},
"no_crossed_threshold": {
"description": "Triggers after one or more nitrogen monoxide levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrogen monoxide level crossed threshold"
},
"ozone_changed": {
"description": "Triggers after one or more ozone levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Ozone level changed"
},
"ozone_crossed_threshold": {
"description": "Triggers after one or more ozone levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Ozone level crossed threshold"
},
"pm10_changed": {
"description": "Triggers after one or more PM10 levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM10 level changed"
},
"pm10_crossed_threshold": {
"description": "Triggers after one or more PM10 levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM10 level crossed threshold"
},
"pm1_changed": {
"description": "Triggers after one or more PM1 levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM1 level changed"
},
"pm1_crossed_threshold": {
"description": "Triggers after one or more PM1 levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM1 level crossed threshold"
},
"pm25_changed": {
"description": "Triggers after one or more PM2.5 levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM2.5 level changed"
},
"pm25_crossed_threshold": {
"description": "Triggers after one or more PM2.5 levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM2.5 level crossed threshold"
},
"pm4_changed": {
"description": "Triggers after one or more PM4 levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM4 level changed"
},
"pm4_crossed_threshold": {
"description": "Triggers after one or more PM4 levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM4 level crossed threshold"
},
"smoke_cleared": {
"description": "Triggers after one or more smoke sensors stop detecting smoke.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Smoke cleared"
},
"smoke_detected": {
"description": "Triggers after one or more smoke sensors start detecting smoke.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Smoke detected"
},
"so2_changed": {
"description": "Triggers after one or more sulphur dioxide levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Sulphur dioxide level changed"
},
"so2_crossed_threshold": {
"description": "Triggers after one or more sulphur dioxide levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Sulphur dioxide level crossed threshold"
},
"voc_changed": {
"description": "Triggers after one or more volatile organic compound levels change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Volatile organic compounds level changed"
},
"voc_crossed_threshold": {
"description": "Triggers after one or more volatile organic compounds levels cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Volatile organic compounds level crossed threshold"
},
"voc_ratio_changed": {
"description": "Triggers after one or more volatile organic compound ratios change.",
"fields": {
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Volatile organic compounds ratio changed"
},
"voc_ratio_crossed_threshold": {
"description": "Triggers after one or more volatile organic compounds ratios cross a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Volatile organic compounds ratio crossed threshold"
}
}
}

View File

@@ -1,206 +0,0 @@
"""Provides triggers for air quality."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_changed_with_unit_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_numerical_state_crossed_threshold_with_unit_trigger,
make_entity_target_state_trigger,
)
from homeassistant.util.unit_conversion import (
CarbonMonoxideConcentrationConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
NitrogenMonoxideConcentrationConverter,
OzoneConcentrationConverter,
SulphurDioxideConcentrationConverter,
UnitlessRatioConverter,
)
def _make_detected_trigger(
device_class: BinarySensorDeviceClass,
) -> type[EntityTargetStateTriggerBase]:
"""Create a detected trigger for a binary sensor device class."""
return make_entity_target_state_trigger(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
)
def _make_cleared_trigger(
device_class: BinarySensorDeviceClass,
) -> type[EntityTargetStateTriggerBase]:
"""Create a cleared trigger for a binary sensor device class."""
return make_entity_target_state_trigger(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
)
TRIGGERS: dict[str, type[Trigger]] = {
# Binary sensor triggers (detected/cleared)
"gas_detected": _make_detected_trigger(BinarySensorDeviceClass.GAS),
"gas_cleared": _make_cleared_trigger(BinarySensorDeviceClass.GAS),
"co_detected": _make_detected_trigger(BinarySensorDeviceClass.CO),
"co_cleared": _make_cleared_trigger(BinarySensorDeviceClass.CO),
"smoke_detected": _make_detected_trigger(BinarySensorDeviceClass.SMOKE),
"smoke_cleared": _make_cleared_trigger(BinarySensorDeviceClass.SMOKE),
# Numerical sensor triggers with unit conversion
"co_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"voc_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
"so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor triggers without unit conversion (single-unit device classes)
"co2_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"co2_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"pm1_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm1_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm25_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm25_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm4_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm4_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm10_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm10_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"n2o_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"n2o_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for air quality."""
return TRIGGERS

View File

@@ -1,617 +0,0 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
# --- Unit lists for multi-unit pollutants ---
.co_units: &co_units
- "ppb"
- "ppm"
- "mg/m³"
- "μg/m³"
.ozone_units: &ozone_units
- "ppb"
- "ppm"
- "μg/m³"
.voc_units: &voc_units
- "μg/m³"
- "mg/m³"
.voc_ratio_units: &voc_ratio_units
- "ppb"
- "ppm"
.no_units: &no_units
- "ppb"
- "μg/m³"
.no2_units: &no2_units
- "ppb"
- "ppm"
- "μg/m³"
.so2_units: &so2_units
- "ppb"
- "μg/m³"
# --- Entity filter anchors ---
.co_threshold_entity: &co_threshold_entity
- domain: input_number
unit_of_measurement: *co_units
- domain: sensor
device_class: carbon_monoxide
- domain: number
device_class: carbon_monoxide
.co2_threshold_entity: &co2_threshold_entity
- domain: input_number
unit_of_measurement: "ppm"
- domain: sensor
device_class: carbon_dioxide
- domain: number
device_class: carbon_dioxide
.pm1_threshold_entity: &pm1_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm1
- domain: number
device_class: pm1
.pm25_threshold_entity: &pm25_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm25
- domain: number
device_class: pm25
.pm4_threshold_entity: &pm4_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm4
- domain: number
device_class: pm4
.pm10_threshold_entity: &pm10_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm10
- domain: number
device_class: pm10
.ozone_threshold_entity: &ozone_threshold_entity
- domain: input_number
unit_of_measurement: *ozone_units
- domain: sensor
device_class: ozone
- domain: number
device_class: ozone
.voc_threshold_entity: &voc_threshold_entity
- domain: input_number
unit_of_measurement: *voc_units
- domain: sensor
device_class: volatile_organic_compounds
- domain: number
device_class: volatile_organic_compounds
.voc_ratio_threshold_entity: &voc_ratio_threshold_entity
- domain: input_number
unit_of_measurement: *voc_ratio_units
- domain: sensor
device_class: volatile_organic_compounds_parts
- domain: number
device_class: volatile_organic_compounds_parts
.no_threshold_entity: &no_threshold_entity
- domain: input_number
unit_of_measurement: *no_units
- domain: sensor
device_class: nitrogen_monoxide
- domain: number
device_class: nitrogen_monoxide
.no2_threshold_entity: &no2_threshold_entity
- domain: input_number
unit_of_measurement: *no2_units
- domain: sensor
device_class: nitrogen_dioxide
- domain: number
device_class: nitrogen_dioxide
.n2o_threshold_entity: &n2o_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: nitrous_oxide
- domain: number
device_class: nitrous_oxide
.so2_threshold_entity: &so2_threshold_entity
- domain: input_number
unit_of_measurement: *so2_units
- domain: sensor
device_class: sulphur_dioxide
- domain: number
device_class: sulphur_dioxide
# --- Number anchors for single-unit pollutants ---
.co2_threshold_number: &co2_threshold_number
mode: box
unit_of_measurement: "ppm"
.ugm3_threshold_number: &ugm3_threshold_number
mode: box
unit_of_measurement: "μg/m³"
# Binary sensor detected/cleared trigger fields
.trigger_binary_fields: &trigger_binary_fields
behavior: *trigger_behavior
# --- Binary sensor targets ---
.target_gas: &target_gas
entity:
- domain: binary_sensor
device_class: gas
.target_co_binary: &target_co_binary
entity:
- domain: binary_sensor
device_class: carbon_monoxide
.target_smoke: &target_smoke
entity:
- domain: binary_sensor
device_class: smoke
# --- Sensor targets ---
.target_co_sensor: &target_co_sensor
entity:
- domain: sensor
device_class: carbon_monoxide
.target_co2: &target_co2
entity:
- domain: sensor
device_class: carbon_dioxide
.target_pm1: &target_pm1
entity:
- domain: sensor
device_class: pm1
.target_pm25: &target_pm25
entity:
- domain: sensor
device_class: pm25
.target_pm4: &target_pm4
entity:
- domain: sensor
device_class: pm4
.target_pm10: &target_pm10
entity:
- domain: sensor
device_class: pm10
.target_ozone: &target_ozone
entity:
- domain: sensor
device_class: ozone
.target_voc: &target_voc
entity:
- domain: sensor
device_class: volatile_organic_compounds
.target_voc_ratio: &target_voc_ratio
entity:
- domain: sensor
device_class: volatile_organic_compounds_parts
.target_no: &target_no
entity:
- domain: sensor
device_class: nitrogen_monoxide
.target_no2: &target_no2
entity:
- domain: sensor
device_class: nitrogen_dioxide
.target_n2o: &target_n2o
entity:
- domain: sensor
device_class: nitrous_oxide
.target_so2: &target_so2
entity:
- domain: sensor
device_class: sulphur_dioxide
# --- Binary sensor triggers ---
gas_detected:
fields: *trigger_binary_fields
target: *target_gas
gas_cleared:
fields: *trigger_binary_fields
target: *target_gas
co_detected:
fields: *trigger_binary_fields
target: *target_co_binary
co_cleared:
fields: *trigger_binary_fields
target: *target_co_binary
smoke_detected:
fields: *trigger_binary_fields
target: *target_smoke
smoke_cleared:
fields: *trigger_binary_fields
target: *target_smoke
# --- Numerical sensor triggers ---
# CO (multi-unit)
co_changed:
target: *target_co_sensor
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *co_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *co_units
co_crossed_threshold:
target: *target_co_sensor
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *co_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *co_units
# CO2 (single-unit: ppm)
co2_changed:
target: *target_co2
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *co2_threshold_entity
mode: changed
number: *co2_threshold_number
co2_crossed_threshold:
target: *target_co2
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *co2_threshold_entity
mode: crossed
number: *co2_threshold_number
# PM1 (single-unit: μg/m³)
pm1_changed:
target: *target_pm1
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *pm1_threshold_entity
mode: changed
number: *ugm3_threshold_number
pm1_crossed_threshold:
target: *target_pm1
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm1_threshold_entity
mode: crossed
number: *ugm3_threshold_number
# PM2.5 (single-unit: μg/m³)
pm25_changed:
target: *target_pm25
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *pm25_threshold_entity
mode: changed
number: *ugm3_threshold_number
pm25_crossed_threshold:
target: *target_pm25
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm25_threshold_entity
mode: crossed
number: *ugm3_threshold_number
# PM4 (single-unit: μg/m³)
pm4_changed:
target: *target_pm4
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *pm4_threshold_entity
mode: changed
number: *ugm3_threshold_number
pm4_crossed_threshold:
target: *target_pm4
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm4_threshold_entity
mode: crossed
number: *ugm3_threshold_number
# PM10 (single-unit: μg/m³)
pm10_changed:
target: *target_pm10
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *pm10_threshold_entity
mode: changed
number: *ugm3_threshold_number
pm10_crossed_threshold:
target: *target_pm10
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm10_threshold_entity
mode: crossed
number: *ugm3_threshold_number
# Ozone (multi-unit)
ozone_changed:
target: *target_ozone
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *ozone_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *ozone_units
ozone_crossed_threshold:
target: *target_ozone
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *ozone_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *ozone_units
# VOC (multi-unit)
voc_changed:
target: *target_voc
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *voc_units
voc_crossed_threshold:
target: *target_voc
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *voc_units
# VOC ratio (multi-unit)
voc_ratio_changed:
target: *target_voc_ratio
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_ratio_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *voc_ratio_units
voc_ratio_crossed_threshold:
target: *target_voc_ratio
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_ratio_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *voc_ratio_units
# NO (multi-unit)
no_changed:
target: *target_no
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *no_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *no_units
no_crossed_threshold:
target: *target_no
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *no_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *no_units
# NO2 (multi-unit)
no2_changed:
target: *target_no2
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *no2_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *no2_units
no2_crossed_threshold:
target: *target_no2
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *no2_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *no2_units
# N2O (single-unit: μg/m³)
n2o_changed:
target: *target_n2o
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *n2o_threshold_entity
mode: changed
number: *ugm3_threshold_number
n2o_crossed_threshold:
target: *target_n2o
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *n2o_threshold_entity
mode: crossed
number: *ugm3_threshold_number
# SO2 (multi-unit)
so2_changed:
target: *target_so2
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *so2_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *so2_units
so2_crossed_threshold:
target: *target_so2
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *so2_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *so2_units

View File

@@ -87,7 +87,7 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Successfully connected to %s", user_input[CONF_IP_ADDRESS])
device_info = await airq.fetch_device_info()
await self.async_set_unique_id(device_info["id"], raise_on_progress=False)
await self.async_set_unique_id(device_info["id"])
self._abort_if_unique_id_configured()
_LOGGER.debug("Creating an entry for %s", device_info["name"])

View File

@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"incomplete_discovery": "The discovered air-Q device did not provide a device ID. Ensure the firmware is up to date."
},
"error": {

View File

@@ -13,9 +13,6 @@ from homeassistant.helpers import (
config_entry_oauth2_flow,
device_registry as dr,
)
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from . import api
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
@@ -28,17 +25,11 @@ async def async_setup_entry(
hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool:
"""Set up Aladdin Connect Genie from a config entry."""
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)

View File

@@ -37,9 +37,6 @@
"close_door_failed": {
"message": "Failed to close the garage door"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"open_door_failed": {
"message": "Failed to open the garage door"
}

View File

@@ -1,13 +1,16 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted alarms.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_armed": {
"description": "Tests if one or more alarms are armed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -17,6 +20,7 @@
"description": "Tests if one or more alarms are armed in away mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -26,6 +30,7 @@
"description": "Tests if one or more alarms are armed in home mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -35,6 +40,7 @@
"description": "Tests if one or more alarms are armed in night mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -44,6 +50,7 @@
"description": "Tests if one or more alarms are armed in vacation mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -53,6 +60,7 @@
"description": "Tests if one or more alarms are disarmed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -62,6 +70,7 @@
"description": "Tests if one or more alarms are triggered.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -164,7 +173,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
}
},
"name": "Arm alarm away"
"name": "Arm away"
},
"alarm_arm_custom_bypass": {
"description": "Arms an alarm while allowing to bypass a custom area.",
@@ -174,7 +183,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
}
},
"name": "Arm alarm with custom bypass"
"name": "Arm with custom bypass"
},
"alarm_arm_home": {
"description": "Arms an alarm in the home mode.",
@@ -184,7 +193,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
}
},
"name": "Arm alarm home"
"name": "Arm home"
},
"alarm_arm_night": {
"description": "Arms an alarm in the night mode.",
@@ -194,7 +203,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
}
},
"name": "Arm alarm night"
"name": "Arm night"
},
"alarm_arm_vacation": {
"description": "Arms an alarm in the vacation mode.",
@@ -204,7 +213,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
}
},
"name": "Arm alarm vacation"
"name": "Arm vacation"
},
"alarm_disarm": {
"description": "Disarms an alarm.",
@@ -214,7 +223,7 @@
"name": "Code"
}
},
"name": "Disarm alarm"
"name": "Disarm"
},
"alarm_trigger": {
"description": "Triggers an alarm manually.",
@@ -224,7 +233,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
}
},
"name": "Trigger alarm"
"name": "Trigger"
}
},
"title": "Alarm control panel",
@@ -233,6 +242,7 @@
"description": "Triggers after one or more alarms become armed, regardless of the mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -242,6 +252,7 @@
"description": "Triggers after one or more alarms become armed in away mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -251,6 +262,7 @@
"description": "Triggers after one or more alarms become armed in home mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -260,6 +272,7 @@
"description": "Triggers after one or more alarms become armed in night mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -269,6 +282,7 @@
"description": "Triggers after one or more alarms become armed in vacation mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -278,6 +292,7 @@
"description": "Triggers after one or more alarms become disarmed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -287,6 +302,7 @@
"description": "Triggers after one or more alarms become triggered.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},

View File

@@ -3,10 +3,10 @@
from __future__ import annotations
import logging
from typing import Any, cast
from typing import Any
from adext import AdExt
from alarmdecoder.devices import Device, SerialDevice, SocketDevice
from alarmdecoder.devices import SerialDevice, SocketDevice
from alarmdecoder.util import NoDeviceError
import voluptuous as vol
@@ -102,21 +102,16 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN):
self._async_current_entries(), user_input, self.protocol
):
return self.async_abort(reason="already_configured")
connection: dict[str, Any] = {}
connection = {}
baud = None
device: Device
if self.protocol == PROTOCOL_SOCKET:
host = connection[CONF_HOST] = cast(str, user_input[CONF_HOST])
port = connection[CONF_PORT] = cast(int, user_input[CONF_PORT])
title: str = f"{host}:{port}"
host = connection[CONF_HOST] = user_input[CONF_HOST]
port = connection[CONF_PORT] = user_input[CONF_PORT]
title = f"{host}:{port}"
device = SocketDevice(interface=(host, port))
if self.protocol == PROTOCOL_SERIAL:
path = connection[CONF_DEVICE_PATH] = cast(
str, user_input[CONF_DEVICE_PATH]
)
baud = connection[CONF_DEVICE_BAUD] = cast(
int, user_input[CONF_DEVICE_BAUD]
)
path = connection[CONF_DEVICE_PATH] = user_input[CONF_DEVICE_PATH]
baud = connection[CONF_DEVICE_BAUD] = user_input[CONF_DEVICE_BAUD]
title = path
device = SerialDevice(interface=path)
@@ -137,7 +132,6 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception during AlarmDecoder setup")
errors["base"] = "unknown"
schema: vol.Schema
if self.protocol == PROTOCOL_SOCKET:
schema = vol.Schema(
{

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.3.2"]
"requirements": ["aioamazondevices==13.0.1"]
}

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass
from python_homeassistant_analytics import (
Environment,
HomeassistantAnalyticsClient,
HomeassistantAnalyticsConnectionError,
)
@@ -39,7 +38,7 @@ async def async_setup_entry(
client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass))
try:
integrations = await client.get_integrations(Environment.NEXT)
integrations = await client.get_integrations()
except HomeassistantAnalyticsConnectionError as ex:
raise ConfigEntryNotReady("Could not fetch integration list") from ex

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyanglianwater"],
"quality_scale": "bronze",
"requirements": ["pyanglianwater==3.1.2"]
"requirements": ["pyanglianwater==3.1.1"]
}

View File

@@ -45,21 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
try:
await client.models.list(timeout=10.0)
except anthropic.AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
raise ConfigEntryAuthFailed(err) from err
except anthropic.AnthropicError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"message": err.message
if isinstance(err, anthropic.APIError)
else str(err)
},
) from err
raise ConfigEntryNotReady(err) from err
entry.runtime_data = client

View File

@@ -12,7 +12,6 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .const import DOMAIN
from .entity import AnthropicBaseLLMEntity
if TYPE_CHECKING:
@@ -61,7 +60,7 @@ class AnthropicTaskEntity(
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="response_not_found"
"Last content in chat log is not an AssistantContent"
)
text = chat_log.content[-1].content or ""
@@ -79,9 +78,7 @@ class AnthropicTaskEntity(
err,
text,
)
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="json_parse_error"
) from err
raise HomeAssistantError("Error with Claude structured response") from err
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,

View File

@@ -71,16 +71,6 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
"claude-haiku-4-5",
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
]
DEPRECATED_MODELS = [
"claude-3",
]

View File

@@ -1,64 +0,0 @@
"""Diagnostics support for Anthropic."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from anthropic import __title__, __version__
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers import entity_registry as er
from .const import (
CONF_PROMPT,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
)
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from . import AnthropicConfigEntry
TO_REDACT = {
CONF_API_KEY,
CONF_PROMPT,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_TIMEZONE,
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AnthropicConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"client": f"{__title__}=={__version__}",
"title": entry.title,
"entry_id": entry.entry_id,
"entry_version": f"{entry.version}.{entry.minor_version}",
"state": entry.state.value,
"data": async_redact_data(entry.data, TO_REDACT),
"options": async_redact_data(entry.options, TO_REDACT),
"subentries": {
subentry.subentry_id: {
"title": subentry.title,
"subentry_type": subentry.subentry_type,
"data": async_redact_data(subentry.data, TO_REDACT),
}
for subentry in entry.subentries.values()
},
"entities": {
entity_entry.entity_id: entity_entry.extended_dict
for entity_entry in er.async_entries_for_config_entry(
er.async_get(hass), entry.entry_id
)
},
}

View File

@@ -19,8 +19,6 @@ from anthropic.types import (
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockParamContentParam,
Container,
ContentBlockParam,
DocumentBlockParam,
@@ -63,16 +61,15 @@ from anthropic.types import (
ToolUseBlockParam,
Usage,
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
WebSearchToolResultBlockParamContentParam,
)
from anthropic.types.bash_code_execution_tool_result_block_param import (
Content as BashCodeExecutionToolResultBlockParamContentParam,
Content as BashCodeExecutionToolResultContentParam,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
Content as TextEditorCodeExecutionToolResultContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -108,7 +105,6 @@ from .const import (
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
)
@@ -228,22 +224,12 @@ def _convert_content(
},
),
}
elif content.tool_name == "code_execution":
tool_result_block = {
"type": "code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
CodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
elif content.tool_name == "bash_code_execution":
tool_result_block = {
"type": "bash_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
BashCodeExecutionToolResultBlockParamContentParam,
content.tool_result,
BashCodeExecutionToolResultContentParam, content.tool_result
),
}
elif content.tool_name == "text_editor_code_execution":
@@ -251,7 +237,7 @@ def _convert_content(
"type": "text_editor_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
TextEditorCodeExecutionToolResultBlockParamContentParam,
TextEditorCodeExecutionToolResultContentParam,
content.tool_result,
),
}
@@ -382,7 +368,6 @@ def _convert_content(
name=cast(
Literal[
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
],
@@ -394,7 +379,6 @@ def _convert_content(
and tool_call.tool_name
in [
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
]
@@ -417,11 +401,7 @@ def _convert_content(
messages[-1]["content"] = messages[-1]["content"][0]["text"]
else:
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unexpected_chat_log_content",
translation_placeholders={"type": type(content).__name__},
)
raise HomeAssistantError("Unexpected content type in chat log")
return messages, container_id
@@ -463,9 +443,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
Each message could contain multiple blocks of the same type.
"""
if stream is None or not hasattr(stream, "__aiter__"):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
)
raise HomeAssistantError("Expected a stream of messages")
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
current_tool_args: str
@@ -486,7 +464,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input=response.content_block.input or {},
input={},
)
current_tool_args = ""
if response.content_block.name == output_tool:
@@ -548,14 +526,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input=response.content_block.input or {},
input={},
)
current_tool_args = ""
elif isinstance(
response.content_block,
(
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
),
@@ -611,13 +588,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] |= tool_args
current_tool_block["input"] = tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
tool_args=current_tool_block["input"],
tool_args=tool_args,
external=current_tool_block["type"] == "server_tool_use",
)
]
@@ -628,9 +605,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
chat_log.async_trace(_create_token_stats(input_usage, usage))
content_details.container = response.delta.container
if response.delta.stop_reason == "refusal":
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_refusal"
)
raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if content_details:
content_details.delete_empty()
@@ -689,9 +664,7 @@ class AnthropicBaseLLMEntity(Entity):
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="system_message_not_found"
)
raise HomeAssistantError("First message must be a system message")
# System prompt with caching enabled
system_prompt: list[TextBlockParam] = [
@@ -752,34 +725,19 @@ class AnthropicBaseLLMEntity(Entity):
]
if options.get(CONF_CODE_EXECUTION):
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_WEB_SEARCH):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
if options.get(CONF_WEB_SEARCH):
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_CODE_EXECUTION):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
)
else:
web_search = WebSearchTool20260209Param(
name="web_search",
type="web_search_20260209",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
web_search = WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = {
"type": "approximate",
@@ -796,7 +754,7 @@ class AnthropicBaseLLMEntity(Entity):
last_message = messages[-1]
if last_message["role"] != "user":
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="user_message_not_found"
"Last message must be a user message to add attachments"
)
if isinstance(last_message["content"], str):
last_message["content"] = [
@@ -901,19 +859,11 @@ class AnthropicBaseLLMEntity(Entity):
except anthropic.AuthenticationError as err:
self.entry.async_start_reauth(self.hass)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
"Authentication error with Anthropic API, reauthentication required"
) from err
except anthropic.AnthropicError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"message": err.message
if isinstance(err, anthropic.APIError)
else str(err)
},
f"Sorry, I had a problem talking to Anthropic: {err}"
) from err
if not chat_log.unresponded_tool_results:
@@ -933,23 +883,15 @@ async def async_prepare_files_for_prompt(
for file_path, mime_type in files:
if not file_path.exists():
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="wrong_file_path",
translation_placeholders={"file_path": file_path.as_posix()},
)
raise HomeAssistantError(f"`{file_path}` does not exist")
if mime_type is None:
mime_type = guess_file_type(file_path)[0]
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="wrong_file_type",
translation_placeholders={
"file_path": file_path.as_posix(),
"mime_type": mime_type or "unknown",
},
"Only images and PDF are supported by the Anthropic API,"
f"`{file_path}` is not an image file or PDF"
)
if mime_type == "image/jpg":
mime_type = "image/jpeg"

View File

@@ -46,7 +46,7 @@ rules:
test-coverage: done
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
@@ -59,11 +59,17 @@ rules:
status: exempt
comment: |
No data updates.
docs-examples: done
docs-examples:
status: todo
comment: |
To give examples of how people use the integration
docs-known-limitations: done
docs-supported-devices: done
docs-supported-devices:
status: todo
comment: |
To write something about what models we support.
docs-supported-functions: done
docs-troubleshooting: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
@@ -82,7 +88,7 @@ rules:
comment: |
No entities disabled by default.
entity-translations: todo
exception-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: done
repair-issues: done

View File

@@ -161,9 +161,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
is None
or (subentry := entry.subentries.get(self._current_subentry_id)) is None
):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="subentry_not_found"
)
raise HomeAssistantError("Subentry not found")
updated_data = {
**subentry.data,
@@ -192,6 +190,4 @@ async def async_create_fix_flow(
"""Create flow."""
if issue_id == "model_deprecated":
return ModelDeprecatedRepairFlow()
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unknown_issue_id"
)
raise HomeAssistantError("Unknown issue ID")

View File

@@ -149,47 +149,6 @@
}
}
},
"exceptions": {
"api_authentication_error": {
"message": "Authentication error with Anthropic API: {message}. Reauthentication required."
},
"api_error": {
"message": "Anthropic API error: {message}."
},
"api_refusal": {
"message": "Potential policy violation detected."
},
"json_parse_error": {
"message": "Error with Claude structured response."
},
"response_not_found": {
"message": "Last content in chat log is not an AssistantContent."
},
"subentry_not_found": {
"message": "Subentry not found."
},
"system_message_not_found": {
"message": "First message must be a system message."
},
"unexpected_chat_log_content": {
"message": "Unexpected content type in chat log: {type}."
},
"unexpected_stream_object": {
"message": "Expected a stream of messages."
},
"unknown_issue_id": {
"message": "Unknown issue ID."
},
"user_message_not_found": {
"message": "Last message must be a user message to add attachments."
},
"wrong_file_path": {
"message": "`{file_path}` does not exist."
},
"wrong_file_type": {
"message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF."
}
},
"issues": {
"model_deprecated": {
"fix_flow": {

View File

@@ -30,10 +30,9 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_CREDENTIALS,
@@ -43,12 +42,9 @@ from .const import (
SIGNAL_CONNECTED,
SIGNAL_DISCONNECTED,
)
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
DEFAULT_NAME_TV = "Apple TV"
DEFAULT_NAME_HP = "HomePod"
@@ -81,12 +77,6 @@ DEVICE_EXCEPTIONS = (
type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Apple TV component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
"""Set up a config entry for Apple TV."""
manager = AppleTVManager(hass, entry)

View File

@@ -9,5 +9,3 @@ CONF_START_OFF = "start_off"
SIGNAL_CONNECTED = "apple_tv_connected"
SIGNAL_DISCONNECTED = "apple_tv_disconnected"
ATTR_TEXT = "text"

View File

@@ -8,16 +8,5 @@
}
}
}
},
"services": {
"append_keyboard_text": {
"service": "mdi:keyboard"
},
"clear_keyboard_text": {
"service": "mdi:keyboard-off"
},
"set_keyboard_text": {
"service": "mdi:keyboard"
}
}
}

View File

@@ -1,130 +0,0 @@
"""Define services for the Apple TV integration."""
from __future__ import annotations
from pyatv.const import KeyboardFocusState
from pyatv.exceptions import NotSupportedError, ProtocolError
from pyatv.interface import AppleTV as AppleTVInterface
import voluptuous as vol
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_TEXT, DOMAIN
SERVICE_SET_KEYBOARD_TEXT = "set_keyboard_text"
SERVICE_SET_KEYBOARD_TEXT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
vol.Required(ATTR_TEXT): cv.string,
}
)
SERVICE_APPEND_KEYBOARD_TEXT = "append_keyboard_text"
SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
vol.Required(ATTR_TEXT): cv.string,
}
)
SERVICE_CLEAR_KEYBOARD_TEXT = "clear_keyboard_text"
SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
}
)
def _get_atv(call: ServiceCall) -> AppleTVInterface:
"""Get the AppleTVInterface for a service call."""
entry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
atv: AppleTVInterface | None = entry.runtime_data.atv
if atv is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="not_connected",
)
return atv
def _check_keyboard_focus(atv: AppleTVInterface) -> None:
"""Check that keyboard is focused on the device."""
try:
focus_state = atv.keyboard.text_focus_state
except NotSupportedError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="keyboard_not_available",
) from err
if focus_state != KeyboardFocusState.Focused:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="keyboard_not_focused",
)
async def _async_set_keyboard_text(call: ServiceCall) -> None:
"""Set text in the keyboard input field on an Apple TV."""
atv = _get_atv(call)
_check_keyboard_focus(atv)
try:
await atv.keyboard.text_set(call.data[ATTR_TEXT])
except ProtocolError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="keyboard_error",
) from err
async def _async_append_keyboard_text(call: ServiceCall) -> None:
"""Append text to the keyboard input field on an Apple TV."""
atv = _get_atv(call)
_check_keyboard_focus(atv)
try:
await atv.keyboard.text_append(call.data[ATTR_TEXT])
except ProtocolError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="keyboard_error",
) from err
async def _async_clear_keyboard_text(call: ServiceCall) -> None:
"""Clear text in the keyboard input field on an Apple TV."""
atv = _get_atv(call)
_check_keyboard_focus(atv)
try:
await atv.keyboard.text_clear()
except ProtocolError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="keyboard_error",
) from err
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Apple TV integration."""
hass.services.async_register(
DOMAIN,
SERVICE_SET_KEYBOARD_TEXT,
_async_set_keyboard_text,
schema=SERVICE_SET_KEYBOARD_TEXT_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_APPEND_KEYBOARD_TEXT,
_async_append_keyboard_text,
schema=SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_CLEAR_KEYBOARD_TEXT,
_async_clear_keyboard_text,
schema=SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA,
)

View File

@@ -1,31 +0,0 @@
set_keyboard_text:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: apple_tv
text:
required: true
selector:
text:
append_keyboard_text:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: apple_tv
text:
required: true
selector:
text:
clear_keyboard_text:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: apple_tv

View File

@@ -69,20 +69,6 @@
}
}
},
"exceptions": {
"keyboard_error": {
"message": "An error occurred while sending text to the Apple TV"
},
"keyboard_not_available": {
"message": "Keyboard input is not supported by this device"
},
"keyboard_not_focused": {
"message": "No text input field is currently focused on the Apple TV"
},
"not_connected": {
"message": "Apple TV is not connected"
}
},
"options": {
"step": {
"init": {
@@ -92,45 +78,5 @@
"description": "Configure general device settings"
}
}
},
"services": {
"append_keyboard_text": {
"description": "Appends text to the currently focused text input field on an Apple TV.",
"fields": {
"config_entry_id": {
"description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]",
"name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]"
},
"text": {
"description": "The text to append.",
"name": "[%key:component::apple_tv::services::set_keyboard_text::fields::text::name%]"
}
},
"name": "Append keyboard text"
},
"clear_keyboard_text": {
"description": "Clears the text in the currently focused text input field on an Apple TV.",
"fields": {
"config_entry_id": {
"description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]",
"name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]"
}
},
"name": "Clear keyboard text"
},
"set_keyboard_text": {
"description": "Sets the text in the currently focused text input field on an Apple TV.",
"fields": {
"config_entry_id": {
"description": "The Apple TV to send text to.",
"name": "Apple TV"
},
"text": {
"description": "The text to set.",
"name": "Text"
}
},
"name": "Set keyboard text"
}
}
}

View File

@@ -2,8 +2,8 @@
import asyncio
from asyncio import timeout
from contextlib import AsyncExitStack
import logging
from typing import Any
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import Client
@@ -54,31 +54,36 @@ async def _run_client(
client = runtime_data.client
coordinators = runtime_data.coordinators
def _listen(_: Any) -> None:
for coordinator in coordinators.values():
coordinator.async_notify_data_updated()
while True:
try:
async with AsyncExitStack() as stack:
async with timeout(interval):
await client.start()
stack.push_async_callback(client.stop)
async with timeout(interval):
await client.start()
_LOGGER.debug("Client connected %s", client.host)
_LOGGER.debug("Client connected %s", client.host)
try:
try:
for coordinator in coordinators.values():
await coordinator.state.start()
with client.listen(_listen):
for coordinator in coordinators.values():
await stack.enter_async_context(
coordinator.async_monitor_client()
)
coordinator.async_notify_connected()
await client.process()
finally:
_LOGGER.debug("Client disconnected %s", client.host)
finally:
await client.stop()
_LOGGER.debug("Client disconnected %s", client.host)
for coordinator in coordinators.values():
coordinator.async_notify_disconnected()
except ConnectionFailed:
pass
await asyncio.sleep(interval)
except TimeoutError:
continue
except Exception:
_LOGGER.exception("Unexpected exception, aborting arcam client")
return
await asyncio.sleep(interval)

View File

@@ -2,13 +2,11 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import dataclass
import logging
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import AmxDuetResponse, Client, ResponsePacket
from arcam.fmj.client import Client
from arcam.fmj.state import State
from homeassistant.config_entries import ConfigEntry
@@ -53,7 +51,7 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
)
self.client = client
self.state = State(client, zone)
self.update_in_progress = False
self.last_update_success = False
name = config_entry.title
unique_id = config_entry.unique_id or config_entry.entry_id
@@ -76,34 +74,24 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None:
"""Fetch data for manual refresh."""
try:
self.update_in_progress = True
await self.state.update()
except ConnectionFailed as err:
raise UpdateFailed(
f"Connection failed during update for zone {self.state.zn}"
) from err
finally:
self.update_in_progress = False
@callback
def _async_notify_packet(self, packet: ResponsePacket | AmxDuetResponse) -> None:
"""Packet callback to detect changes to state."""
if (
not isinstance(packet, ResponsePacket)
or packet.zn != self.state.zn
or self.update_in_progress
):
return
def async_notify_data_updated(self) -> None:
"""Notify that new data has been received from the device."""
self.async_set_updated_data(None)
@callback
def async_notify_connected(self) -> None:
"""Handle client connected."""
self.hass.async_create_task(self.async_refresh())
@callback
def async_notify_disconnected(self) -> None:
"""Handle client disconnected."""
self.last_update_success = False
self.async_update_listeners()
@asynccontextmanager
async def async_monitor_client(self) -> AsyncGenerator[None]:
"""Monitor a client and state for changes while connected."""
async with self.state:
self.hass.async_create_task(self.async_refresh())
try:
with self.client.listen(self._async_notify_packet):
yield
finally:
self.hass.async_create_task(self.async_refresh())

View File

@@ -26,8 +26,3 @@ class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
if description is not None:
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
self.entity_description = description
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.client.connected

View File

@@ -1 +1 @@
"""The Arris TG2492LG integration."""
"""The Arris TG2492LG component."""

View File

@@ -1 +1 @@
"""The Aruba integration."""
"""The aruba component."""

View File

@@ -137,4 +137,5 @@ async def async_pipeline_from_audio_stream(
audio_settings=audio_settings or AudioSettings(),
),
)
await pipeline_input.execute(validate=True)
await pipeline_input.validate()
await pipeline_input.execute()

View File

@@ -1,14 +1,7 @@
"""Assist pipeline errors."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.exceptions import HomeAssistantError
if TYPE_CHECKING:
from .pipeline import PipelineStage
class PipelineError(HomeAssistantError):
"""Base class for pipeline errors."""
@@ -62,25 +55,3 @@ class IntentRecognitionError(PipelineError):
class TextToSpeechError(PipelineError):
"""Error in text-to-speech portion of pipeline."""
class PipelineRunValidationError(PipelineError):
"""Error when a pipeline run is not valid."""
def __init__(self, message: str) -> None:
"""Set error message."""
super().__init__("validation-error", message)
class InvalidPipelineStagesError(PipelineRunValidationError):
"""Error when given an invalid combination of start/end stages."""
def __init__(
self,
start_stage: PipelineStage,
end_stage: PipelineStage,
) -> None:
"""Set error message."""
super().__init__(
f"Invalid stage combination: start={start_stage}, end={end_stage}"
)

View File

@@ -73,10 +73,8 @@ from .const import (
from .error import (
DuplicateWakeUpDetectedError,
IntentRecognitionError,
InvalidPipelineStagesError,
PipelineError,
PipelineNotFound,
PipelineRunValidationError,
SpeechToTextError,
TextToSpeechError,
WakeWordDetectionAborted,
@@ -494,6 +492,24 @@ PIPELINE_STAGE_ORDER = [
]
class PipelineRunValidationError(Exception):
"""Error when a pipeline run is not valid."""
class InvalidPipelineStagesError(PipelineRunValidationError):
"""Error when given an invalid combination of start/end stages."""
def __init__(
self,
start_stage: PipelineStage,
end_stage: PipelineStage,
) -> None:
"""Set error message."""
super().__init__(
f"Invalid stage combination: start={start_stage}, end={end_stage}"
)
@dataclass(frozen=True)
class WakeWordSettings:
"""Settings for wake word detection."""
@@ -646,8 +662,7 @@ class PipelineRun:
"""Emit run start event."""
self._device_id = device_id
self._satellite_id = satellite_id
if self.start_stage in (PipelineStage.WAKE_WORD, PipelineStage.STT):
self._start_debug_recording_thread()
self._start_debug_recording_thread()
data: dict[str, Any] = {
"pipeline": self.pipeline.id,
@@ -1489,7 +1504,9 @@ class PipelineRun:
def _start_debug_recording_thread(self) -> None:
"""Start thread to record wake/stt audio if debug_recording_dir is set."""
assert self.debug_recording_thread is None
if self.debug_recording_thread is not None:
# Already started
return
# Directory to save audio for each pipeline run.
# Configured in YAML for assist_pipeline.
@@ -1664,39 +1681,26 @@ class PipelineInput:
satellite_id: str | None = None
"""Identifier of the satellite that is processing the input/output of the pipeline."""
async def execute(self, validate: bool = False) -> None:
async def execute(self) -> None:
"""Run pipeline."""
validation_error: PipelineError | None = None
if validate:
try:
await self.validate()
except PipelineError as err:
validation_error = err
self.run.start(
conversation_id=self.session.conversation_id,
device_id=self.device_id,
satellite_id=self.satellite_id,
)
current_stage: PipelineStage | None = self.run.start_stage
stt_audio_buffer: list[EnhancedAudioChunk] = []
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
if self.stt_stream is not None:
if self.run.audio_settings.needs_processor:
# VAD/noise suppression/auto gain/volume
stt_processed_stream = self.run.process_enhance_audio(self.stt_stream)
else:
# Volume multiplier only
stt_processed_stream = self.run.process_volume_only(self.stt_stream)
try:
if validation_error is not None:
raise validation_error
stt_audio_buffer: list[EnhancedAudioChunk] = []
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
if self.stt_stream is not None:
if self.run.audio_settings.needs_processor:
# VAD/noise suppression/auto gain/volume
stt_processed_stream = self.run.process_enhance_audio(
self.stt_stream
)
else:
# Volume multiplier only
stt_processed_stream = self.run.process_volume_only(self.stt_stream)
if current_stage == PipelineStage.WAKE_WORD:
# wake-word-detection
assert stt_processed_stream is not None

View File

@@ -1,13 +1,16 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted Assist satellites.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_idle": {
"description": "Tests if one or more Assist satellites are idle.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
@@ -17,6 +20,7 @@
"description": "Tests if one or more Assist satellites are listening.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
@@ -26,6 +30,7 @@
"description": "Tests if one or more Assist satellites are processing.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
@@ -35,6 +40,7 @@
"description": "Tests if one or more Assist satellites are responding.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
@@ -159,6 +165,7 @@
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
@@ -168,6 +175,7 @@
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
@@ -177,6 +185,7 @@
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
@@ -186,6 +195,7 @@
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},

View File

@@ -626,7 +626,7 @@ def websocket_delete_all_refresh_tokens(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle delete all refresh tokens request."""
current_refresh_token: RefreshToken | None = None
current_refresh_token: RefreshToken
remove_failed = False
token_type = msg.get("token_type")
delete_current_token = msg.get("delete_current_token")
@@ -654,7 +654,7 @@ def websocket_delete_all_refresh_tokens(
else:
connection.send_result(msg["id"], {})
async def _delete_current_token_soon(current_refresh_token: RefreshToken) -> None:
async def _delete_current_token_soon() -> None:
"""Delete the current token after a delay.
We do not want to delete the current token immediately as it will
@@ -675,15 +675,13 @@ def websocket_delete_all_refresh_tokens(
# the token right away.
hass.auth.async_remove_refresh_token(current_refresh_token)
if (
delete_current_token
and current_refresh_token
and (not limit_token_types or current_refresh_token.token_type == token_type)
if delete_current_token and (
not limit_token_types or current_refresh_token.token_type == token_type
):
# Deleting the token will close the connection so we need
# to do it with a delay in a tracked task to ensure it still
# happens if Home Assistant is shutting down.
hass.async_create_task(_delete_current_token_soon(current_refresh_token))
hass.async_create_task(_delete_current_token_soon())
@websocket_api.websocket_command(

View File

@@ -115,7 +115,6 @@ def async_setup(
) -> None:
"""Component to allow users to login."""
hass.http.register_view(WellKnownOAuthInfoView)
hass.http.register_view(WellKnownProtectedResourceView)
hass.http.register_view(AuthProvidersView)
hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow, store_result))
hass.http.register_view(LoginFlowResourceView(hass.auth.login_flow, store_result))
@@ -142,13 +141,6 @@ class WellKnownOAuthInfoView(HomeAssistantView):
"authorization_endpoint": f"{url_prefix}/auth/authorize",
"token_endpoint": f"{url_prefix}/auth/token",
"revocation_endpoint": f"{url_prefix}/auth/revoke",
# Home Assistant already accepts URL-based client_ids via
# IndieAuth without prior registration, which is compatible with
# draft-ietf-oauth-client-id-metadata-document. This flag
# advertises that support to encourage clients to use it. The
# metadata document is not actually fetched as IndieAuth doesn't
# require it.
"client_id_metadata_document_supported": True,
"response_types_supported": ["code"],
"service_documentation": (
"https://developers.home-assistant.io/docs/auth_api"
@@ -162,32 +154,6 @@ class WellKnownOAuthInfoView(HomeAssistantView):
return self.json(metadata)
class WellKnownProtectedResourceView(HomeAssistantView):
"""View to host the OAuth2 Protected Resource Metadata per RFC9728."""
requires_auth = False
url = "/.well-known/oauth-protected-resource"
name = "well-known/oauth-protected-resource"
async def get(self, request: web.Request) -> web.Response:
"""Return the protected resource metadata."""
hass = request.app[KEY_HASS]
try:
url_prefix = get_url(hass, require_current_request=True)
except NoURLAvailableError:
return self.json_message("No URL available", HTTPStatus.NOT_FOUND)
return self.json(
{
"resource": url_prefix,
"authorization_servers": [url_prefix],
"resource_documentation": (
"https://developers.home-assistant.io/docs/auth_api"
),
}
)
class AuthProvidersView(HomeAssistantView):
"""View to get available auth providers."""

View File

@@ -118,13 +118,9 @@ SERVICE_TRIGGER = "trigger"
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"air_quality",
"alarm_control_panel",
"assist_satellite",
"battery",
"calendar",
"climate",
"counter",
"cover",
"device_tracker",
"door",
@@ -132,70 +128,50 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"garage_door",
"gate",
"humidifier",
"humidity",
"illuminance",
"lawn_mower",
"light",
"lock",
"media_player",
"moisture",
"motion",
"occupancy",
"person",
"power",
"schedule",
"select",
"siren",
"switch",
"temperature",
"text",
"timer",
"vacuum",
"valve",
"water_heater",
"window",
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
"air_quality",
"alarm_control_panel",
"assist_satellite",
"battery",
"button",
"climate",
"counter",
"cover",
"device_tracker",
"door",
"event",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"illuminance",
"input_boolean",
"lawn_mower",
"light",
"lock",
"media_player",
"moisture",
"motion",
"occupancy",
"person",
"power",
"remote",
"scene",
"schedule",
"select",
"siren",
"switch",
"temperature",
"text",
"todo",
"update",
"vacuum",
"valve",
"water_heater",
"window",
}

View File

@@ -78,11 +78,11 @@
"services": {
"reload": {
"description": "Reloads the automation configuration.",
"name": "Reload automations"
"name": "[%key:common::action::reload%]"
},
"toggle": {
"description": "Toggles (enable / disable) an automation.",
"name": "Toggle automation"
"name": "[%key:common::action::toggle%]"
},
"trigger": {
"description": "Triggers the actions of an automation.",
@@ -92,7 +92,7 @@
"name": "Skip conditions"
}
},
"name": "Trigger automation"
"name": "Trigger"
},
"turn_off": {
"description": "Disables an automation.",
@@ -102,11 +102,11 @@
"name": "Stop actions"
}
},
"name": "Turn off automation"
"name": "[%key:common::action::turn_off%]"
},
"turn_on": {
"description": "Enables an automation.",
"name": "Turn on automation"
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Automation"

View File

@@ -147,7 +147,7 @@ class S3BackupAgent(BackupAgent):
if backup.size < MULTIPART_MIN_PART_SIZE_BYTES:
await self._upload_simple(tar_filename, open_stream)
else:
await self._upload_multipart(tar_filename, open_stream, on_progress)
await self._upload_multipart(tar_filename, open_stream)
# Upload the metadata file
metadata_content = json.dumps(backup.as_dict())
@@ -188,13 +188,11 @@ class S3BackupAgent(BackupAgent):
self,
tar_filename: str,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
on_progress: OnProgressCallback,
) -> None:
):
"""Upload a large file using multipart upload.
:param tar_filename: The target filename for the backup.
:param open_stream: A function returning an async iterator that yields bytes.
:param on_progress: A callback to report the number of uploaded bytes.
"""
_LOGGER.debug("Starting multipart upload for %s", tar_filename)
multipart_upload = await self._client.create_multipart_upload(
@@ -207,7 +205,6 @@ class S3BackupAgent(BackupAgent):
part_number = 1
buffer = bytearray() # bytes buffer to store the data
offset = 0 # start index of unread data inside buffer
bytes_uploaded = 0
stream = await open_stream()
async for chunk in stream:
@@ -236,8 +233,6 @@ class S3BackupAgent(BackupAgent):
Body=part_data.tobytes(),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(part_data)
on_progress(bytes_uploaded=bytes_uploaded)
part_number += 1
finally:
view.release()
@@ -266,8 +261,6 @@ class S3BackupAgent(BackupAgent):
Body=remaining_data.tobytes(),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(remaining_data)
on_progress(bytes_uploaded=bytes_uploaded)
await cast(Any, self._client).complete_multipart_upload(
Bucket=self._bucket,

View File

@@ -34,4 +34,4 @@ EXCLUDE_DATABASE_FROM_BACKUP = [
"home-assistant_v2.db-wal",
]
SECURETAR_CREATE_VERSION = 3
SECURETAR_CREATE_VERSION = 2

View File

@@ -12,7 +12,7 @@ import hashlib
import io
from itertools import chain
import json
from pathlib import Path, PurePath, PureWindowsPath
from pathlib import Path, PurePath
import shutil
import sys
import tarfile
@@ -1957,10 +1957,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
suggested_filename: str,
) -> WrittenBackup:
"""Receive a backup."""
safe_filename = PureWindowsPath(suggested_filename).name
if not safe_filename or safe_filename == "..":
safe_filename = "backup.tar"
temp_file = Path(self.temp_backup_dir, safe_filename)
temp_file = Path(self.temp_backup_dir, suggested_filename)
async_add_executor_job = self._hass.async_add_executor_job
await async_add_executor_job(make_backup_dir, self.temp_backup_dir)

View File

@@ -1,17 +0,0 @@
"""Integration for battery triggers and conditions."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "battery"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -1,46 +0,0 @@
"""Provides conditions for batteries."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
make_entity_numerical_condition,
make_entity_state_condition,
)
BATTERY_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.BATTERY)
}
BATTERY_CHARGING_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(
device_class=BinarySensorDeviceClass.BATTERY_CHARGING
)
}
BATTERY_PERCENTAGE_DOMAIN_SPECS = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY),
}
CONDITIONS: dict[str, type[Condition]] = {
"is_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_ON),
"is_not_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_OFF),
"is_charging": make_entity_state_condition(BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON),
"is_not_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
),
"is_level": make_entity_numerical_condition(
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for batteries."""
return CONDITIONS

View File

@@ -1,64 +0,0 @@
.condition_common: &condition_common
target: &target_battery_binary_sensor
entity:
- domain: binary_sensor
device_class: battery
fields:
behavior: &condition_behavior
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: sensor
device_class: battery
- domain: number
device_class: battery
.battery_threshold_number: &battery_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
is_low: *condition_common
is_not_low: *condition_common
is_charging:
target:
entity:
- domain: binary_sensor
device_class: battery_charging
fields:
behavior: *condition_behavior
is_not_charging:
target:
entity:
- domain: binary_sensor
device_class: battery_charging
fields:
behavior: *condition_behavior
is_level:
target:
entity:
- domain: sensor
device_class: battery
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *battery_threshold_entity
mode: is
number: *battery_threshold_number

View File

@@ -1,39 +0,0 @@
{
"conditions": {
"is_charging": {
"condition": "mdi:battery-charging"
},
"is_level": {
"condition": "mdi:battery-unknown"
},
"is_low": {
"condition": "mdi:battery-alert"
},
"is_not_charging": {
"condition": "mdi:battery"
},
"is_not_low": {
"condition": "mdi:battery"
}
},
"triggers": {
"level_changed": {
"trigger": "mdi:battery-unknown"
},
"level_crossed_threshold": {
"trigger": "mdi:battery-alert"
},
"low": {
"trigger": "mdi:battery-alert"
},
"not_low": {
"trigger": "mdi:battery"
},
"started_charging": {
"trigger": "mdi:battery-charging"
},
"stopped_charging": {
"trigger": "mdi:battery"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"domain": "battery",
"name": "Battery",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/battery",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -1,133 +0,0 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_threshold_name": "Threshold type"
},
"conditions": {
"is_charging": {
"description": "Tests if one or more batteries are charging.",
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is charging"
},
"is_level": {
"description": "Tests the battery level of one or more batteries.",
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"threshold": {
"name": "[%key:component::battery::common::condition_threshold_name%]"
}
},
"name": "Battery level"
},
"is_low": {
"description": "Tests if one or more batteries are low.",
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is low"
},
"is_not_charging": {
"description": "Tests if one or more batteries are not charging.",
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is not charging"
},
"is_not_low": {
"description": "Tests if one or more batteries are not low.",
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is not low"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Battery",
"triggers": {
"level_changed": {
"description": "Triggers after the battery level of one or more batteries changes.",
"fields": {
"threshold": {
"name": "[%key:component::battery::common::trigger_threshold_name%]"
}
},
"name": "Battery level changed"
},
"level_crossed_threshold": {
"description": "Triggers after the battery level of one or more batteries crosses a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"threshold": {
"name": "[%key:component::battery::common::trigger_threshold_name%]"
}
},
"name": "Battery level crossed threshold"
},
"low": {
"description": "Triggers after one or more batteries become low.",
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery low"
},
"not_low": {
"description": "Triggers after one or more batteries are no longer low.",
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery not low"
},
"started_charging": {
"description": "Triggers after one or more batteries start charging.",
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery started charging"
},
"stopped_charging": {
"description": "Triggers after one or more batteries stop charging.",
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery stopped charging"
}
}
}

View File

@@ -1,54 +0,0 @@
"""Provides triggers for batteries."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
)
BATTERY_LOW_DOMAIN_SPECS: dict[str, DomainSpec] = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.BATTERY),
}
BATTERY_CHARGING_DOMAIN_SPECS: dict[str, DomainSpec] = {
BINARY_SENSOR_DOMAIN: DomainSpec(
device_class=BinarySensorDeviceClass.BATTERY_CHARGING
),
}
BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY),
}
TRIGGERS: dict[str, type[Trigger]] = {
"low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON),
"not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF),
"started_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON
),
"stopped_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
),
"level_changed": make_entity_numerical_state_changed_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
),
"level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for batteries."""
return TRIGGERS

View File

@@ -1,83 +0,0 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
device_class: battery
- domain: sensor
device_class: battery
.battery_threshold_number: &battery_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
.trigger_target_battery: &trigger_target_battery
entity:
- domain: binary_sensor
device_class: battery
.trigger_target_charging: &trigger_target_charging
entity:
- domain: binary_sensor
device_class: battery_charging
.trigger_target_percentage: &trigger_target_percentage
entity:
- domain: sensor
device_class: battery
low:
fields:
behavior: *trigger_behavior
target: *trigger_target_battery
not_low:
fields:
behavior: *trigger_behavior
target: *trigger_target_battery
started_charging:
fields:
behavior: *trigger_behavior
target: *trigger_target_charging
stopped_charging:
fields:
behavior: *trigger_behavior
target: *trigger_target_charging
level_changed:
target: *trigger_target_percentage
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *battery_threshold_entity
mode: changed
number: *battery_threshold_number
level_crossed_threshold:
target: *trigger_target_percentage
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *battery_threshold_entity
mode: crossed
number: *battery_threshold_number

View File

@@ -1 +1 @@
"""The Bbox integration."""
"""The bbox component."""

View File

@@ -1 +1 @@
"""The Bitcoin integration."""
"""The bitcoin component."""

View File

@@ -1,7 +1,7 @@
{
"domain": "blebox",
"name": "BleBox devices",
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
"codeowners": ["@bbx-a", "@swistakm"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/blebox",
"integration_type": "device",

View File

@@ -1 +1 @@
"""The BlinkStick integration."""
"""The blinksticklight component."""

View File

@@ -1,4 +1,4 @@
"""Support for BlinkStick lights."""
"""Support for Blinkstick lights."""
# mypy: ignore-errors
from __future__ import annotations
@@ -40,7 +40,7 @@ def setup_platform(
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up BlinkStick device specified by serial number."""
"""Set up Blinkstick device specified by serial number."""
name = config[CONF_NAME]
serial = config[CONF_SERIAL]

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyblu==2.0.6"],
"requirements": ["pyblu==2.0.5"],
"zeroconf": [
{
"type": "_musc._tcp.local."

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==3.1.2",
"habluetooth==5.11.1"
"habluetooth==5.10.2"
]
}

View File

@@ -1,41 +0,0 @@
"""The BMW Connected Drive integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
DOMAIN = "bmw_connected_drive"
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up BMW Connected Drive from a config entry."""
ir.async_create_issue(
hass,
DOMAIN,
DOMAIN,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="integration_removed",
translation_placeholders={
"entries": "/config/integrations/integration/bmw_connected_drive",
"custom_component_url": "https://github.com/kvanbiesen/bmw-cardata-ha",
},
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))

View File

@@ -1,9 +0,0 @@
"""The BMW Connected Drive integration config flow."""
from homeassistant.config_entries import ConfigFlow
from . import DOMAIN
class BMWConnectedDriveConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for BMW Connected Drive."""

View File

@@ -1,10 +0,0 @@
{
"domain": "bmw_connected_drive",
"name": "BMW Connected Drive",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"integration_type": "system",
"iot_class": "cloud_polling",
"quality_scale": "legacy",
"requirements": []
}

View File

@@ -1,8 +0,0 @@
{
"issues": {
"integration_removed": {
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
"title": "The BMW Connected Drive integration has been removed"
}
}
}

View File

@@ -52,9 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Rotate the access token."""
access_tokens.append(hex(_RND.getrandbits(256))[2:])
async_track_time_interval(
hass, _rotate_token, TOKEN_CHANGE_INTERVAL, cancel_on_shutdown=True
)
async_track_time_interval(hass, _rotate_token, TOKEN_CHANGE_INTERVAL)
hass.http.register_view(BrandsIntegrationView(hass))
hass.http.register_view(BrandsHardwareView(hass))

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.1.3"],
"requirements": ["python-bsblan==5.1.2"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -23,8 +23,8 @@
},
"services": {
"press": {
"description": "Presses a button.",
"name": "Press button"
"description": "Presses a button entity.",
"name": "Press"
}
},
"title": "Button",

View File

@@ -578,13 +578,13 @@ class CalendarEntity(Entity):
return STATE_OFF
@callback
def _async_write_ha_state(self) -> None:
def async_write_ha_state(self) -> None:
"""Write the state to the state machine.
This sets up listeners to handle state transitions for start or end of
the current or upcoming event.
"""
super()._async_write_ha_state()
super().async_write_ha_state()
if self._alarm_unsubs is None:
self._alarm_unsubs = []
_LOGGER.debug(

View File

@@ -1,16 +0,0 @@
"""Provides conditions for calendars."""
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_event_active": make_entity_state_condition(DOMAIN, STATE_ON),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the calendar conditions."""
return CONDITIONS

View File

@@ -1,14 +0,0 @@
is_event_active:
target:
entity:
- domain: calendar
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any

View File

@@ -1,9 +1,4 @@
{
"conditions": {
"is_event_active": {
"condition": "mdi:calendar-check"
}
},
"entity_component": {
"_": {
"default": "mdi:calendar",

View File

@@ -1,18 +1,4 @@
{
"common": {
"condition_behavior_name": "Condition passes if"
},
"conditions": {
"is_event_active": {
"description": "Tests if one or more calendars have an active event.",
"fields": {
"behavior": {
"name": "[%key:component::calendar::common::condition_behavior_name%]"
}
},
"name": "Calendar event is active"
}
},
"entity_component": {
"_": {
"name": "[%key:component::calendar::title%]",
@@ -60,12 +46,6 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_offset_type": {
"options": {
"after": "After",
@@ -110,7 +90,7 @@
"name": "Summary"
}
},
"name": "Create calendar event"
"name": "Create event"
},
"get_events": {
"description": "Retrieves events on a calendar within a time range.",
@@ -128,7 +108,7 @@
"name": "Start time"
}
},
"name": "Get calendar events"
"name": "Get events"
}
},
"title": "Calendar",

View File

@@ -432,7 +432,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
)
# Entity Properties
entity_description: CameraEntityDescription
_attr_brand: str | None = None
_attr_frame_interval: float = MIN_STREAM_INTERVAL
_attr_is_on: bool = True
@@ -760,12 +759,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return CameraCapabilities(frontend_stream_types)
@callback
def _async_write_ha_state(self) -> None:
def async_write_ha_state(self) -> None:
"""Write the state to the state machine.
Schedules async_refresh_providers if support of streams have changed.
"""
super()._async_write_ha_state()
super().async_write_ha_state()
if self.__supports_stream != (
supports_stream := self.supported_features & CameraEntityFeature.STREAM
):

View File

@@ -51,11 +51,11 @@
"services": {
"disable_motion_detection": {
"description": "Disables the motion detection of a camera.",
"name": "Disable camera motion detection"
"name": "Disable motion detection"
},
"enable_motion_detection": {
"description": "Enables the motion detection of a camera.",
"name": "Enable camera motion detection"
"name": "Enable motion detection"
},
"play_stream": {
"description": "Plays a camera stream on a supported media player.",
@@ -69,7 +69,7 @@
"name": "Media player"
}
},
"name": "Play camera stream"
"name": "Play stream"
},
"record": {
"description": "Creates a recording of a live camera feed.",
@@ -87,7 +87,7 @@
"name": "Lookback"
}
},
"name": "Record camera feed"
"name": "Record"
},
"snapshot": {
"description": "Takes a snapshot from a camera.",
@@ -97,15 +97,15 @@
"name": "Filename"
}
},
"name": "Take camera snapshot"
"name": "Take snapshot"
},
"turn_off": {
"description": "Turns off a camera.",
"name": "Turn off camera"
"name": "[%key:common::action::turn_off%]"
},
"turn_on": {
"description": "Turns on a camera.",
"name": "Turn on camera"
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Camera"

View File

@@ -11,12 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.SELECT,
]
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:

View File

@@ -1,51 +0,0 @@
"""Casper Glow integration binary sensor platform."""
from __future__ import annotations
from pycasperglow import GlowState
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensor platform for Casper Glow."""
async_add_entities([CasperGlowPausedBinarySensor(entry.runtime_data)])
class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity):
"""Binary sensor indicating whether the Casper Glow dimming is paused."""
_attr_translation_key = "paused"
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize the paused binary sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_paused"
if coordinator.device.state.is_paused is not None:
self._attr_is_on = coordinator.device.state.is_paused
async def async_added_to_hass(self) -> None:
"""Register state update callback when entity is added."""
await super().async_added_to_hass()
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
if state.is_paused is not None:
self._attr_is_on = state.is_paused
self.async_write_ha_state()

View File

@@ -1,73 +0,0 @@
"""Casper Glow integration button platform."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pycasperglow import CasperGlow
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class CasperGlowButtonEntityDescription(ButtonEntityDescription):
"""Describe a Casper Glow button entity."""
press_fn: Callable[[CasperGlow], Awaitable[None]]
BUTTON_DESCRIPTIONS: tuple[CasperGlowButtonEntityDescription, ...] = (
CasperGlowButtonEntityDescription(
key="pause",
translation_key="pause",
press_fn=lambda device: device.pause(),
),
CasperGlowButtonEntityDescription(
key="resume",
translation_key="resume",
press_fn=lambda device: device.resume(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the button platform for Casper Glow."""
async_add_entities(
CasperGlowButton(entry.runtime_data, description)
for description in BUTTON_DESCRIPTIONS
)
class CasperGlowButton(CasperGlowEntity, ButtonEntity):
"""A Casper Glow button entity."""
entity_description: CasperGlowButtonEntityDescription
def __init__(
self,
coordinator: CasperGlowCoordinator,
description: CasperGlowButtonEntityDescription,
) -> None:
"""Initialize a Casper Glow button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = (
f"{format_mac(coordinator.device.address)}_{description.key}"
)
async def async_press(self) -> None:
"""Press the button."""
await self._async_command(self.entity_description.press_fn(self._device))

View File

@@ -12,7 +12,5 @@ SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS)
DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0]
DIMMING_TIME_OPTIONS: tuple[str, ...] = tuple(str(m) for m in DIMMING_TIME_MINUTES)
# Interval between periodic state polls to catch externally-triggered changes.
STATE_POLL_INTERVAL = timedelta(seconds=30)

View File

@@ -19,7 +19,7 @@ from homeassistant.components.bluetooth.active_update_coordinator import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from .const import SORTED_BRIGHTNESS_LEVELS, STATE_POLL_INTERVAL
from .const import STATE_POLL_INTERVAL
_LOGGER = logging.getLogger(__name__)
@@ -51,15 +51,6 @@ class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]):
)
self.title = title
# The device API couples brightness and dimming time into a
# single command (set_brightness_and_dimming_time), so both
# values must be tracked here for cross-entity use.
self.last_brightness_pct: int = (
device.state.brightness_level
if device.state.brightness_level is not None
else SORTED_BRIGHTNESS_LEVELS[0]
)
@callback
def _needs_poll(
self,

View File

@@ -1,31 +0,0 @@
"""Diagnostics support for the Casper Glow integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components import bluetooth
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import CasperGlowConfigEntry
SERVICE_INFO_TO_REDACT = frozenset({"address", "name", "source", "device"})
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: CasperGlowConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
service_info = bluetooth.async_last_service_info(
hass, coordinator.device.address, connectable=True
)
return {
"service_info": async_redact_data(
service_info.as_dict() if service_info else None,
SERVICE_INFO_TO_REDACT,
),
}

View File

@@ -1,22 +0,0 @@
{
"entity": {
"binary_sensor": {
"paused": {
"default": "mdi:timer-pause"
}
},
"button": {
"pause": {
"default": "mdi:pause"
},
"resume": {
"default": "mdi:play"
}
},
"select": {
"dimming_time": {
"default": "mdi:timer-outline"
}
}
}
}

View File

@@ -71,7 +71,6 @@ class CasperGlowLight(CasperGlowEntity, LightEntity):
self._attr_color_mode = ColorMode.BRIGHTNESS
if state.brightness_level is not None:
self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level)
self.coordinator.last_brightness_pct = state.brightness_level
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
@@ -98,7 +97,6 @@ class CasperGlowLight(CasperGlowEntity, LightEntity):
)
)
self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct)
self.coordinator.last_brightness_pct = brightness_pct
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""

View File

@@ -14,6 +14,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pycasperglow"],
"quality_scale": "silver",
"requirements": ["pycasperglow==1.2.0"]
"quality_scale": "bronze",
"requirements": ["pycasperglow==1.1.0"]
}

View File

@@ -32,14 +32,12 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Bluetooth device with no authentication credentials.
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: No network discovery.
@@ -52,14 +50,18 @@ rules:
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class:
status: exempt
comment: No applicable device classes for binary_sensor, button, light, or select entities.
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: done
entity-translations:
status: exempt
comment: No entity translations needed.
exception-translations:
status: exempt
comment: No custom services that raise exceptions.
icon-translations:
status: exempt
comment: No icon translations needed.
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo

View File

@@ -1,92 +0,0 @@
"""Casper Glow integration select platform for dimming time."""
from __future__ import annotations
from pycasperglow import GlowState
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DIMMING_TIME_OPTIONS
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the select platform for Casper Glow."""
async_add_entities([CasperGlowDimmingTimeSelect(entry.runtime_data)])
class CasperGlowDimmingTimeSelect(CasperGlowEntity, SelectEntity, RestoreEntity):
"""Select entity for Casper Glow dimming time."""
_attr_translation_key = "dimming_time"
_attr_entity_category = EntityCategory.CONFIG
_attr_options = list(DIMMING_TIME_OPTIONS)
_attr_unit_of_measurement = UnitOfTime.MINUTES
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize the dimming time select entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_dimming_time"
@property
def current_option(self) -> str | None:
"""Return the currently selected dimming time from the coordinator."""
if self.coordinator.last_dimming_time_minutes is None:
return None
return str(self.coordinator.last_dimming_time_minutes)
async def async_added_to_hass(self) -> None:
"""Restore last known dimming time and register state update callback."""
await super().async_added_to_hass()
if self.coordinator.last_dimming_time_minutes is None and (
last_state := await self.async_get_last_state()
):
if last_state.state in DIMMING_TIME_OPTIONS:
self.coordinator.last_dimming_time_minutes = int(last_state.state)
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
if state.brightness_level is not None:
self.coordinator.last_brightness_pct = state.brightness_level
if (
state.configured_dimming_time_minutes is not None
and self.coordinator.last_dimming_time_minutes is None
):
self.coordinator.last_dimming_time_minutes = (
state.configured_dimming_time_minutes
)
# Dimming time is not part of the device state
# that is provided via BLE update. Therefore
# we need to trigger a state update for the select entity
# to update the current state.
self.async_write_ha_state()
async def async_select_option(self, option: str) -> None:
"""Set the dimming time."""
await self._async_command(
self._device.set_brightness_and_dimming_time(
self.coordinator.last_brightness_pct, int(option)
)
)
self.coordinator.last_dimming_time_minutes = int(option)
# Dimming time is not part of the device state
# that is provided via BLE update. Therefore
# we need to trigger a state update for the select entity
# to update the current state.
self.async_write_ha_state()

View File

@@ -26,26 +26,6 @@
}
}
},
"entity": {
"binary_sensor": {
"paused": {
"name": "Dimming paused"
}
},
"button": {
"pause": {
"name": "Pause dimming"
},
"resume": {
"name": "Resume dimming"
}
},
"select": {
"dimming_time": {
"name": "Dimming time"
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the Casper Glow: {error}"

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