Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
58748c7665 Update astroid to 4.1.2 2026-04-15 05:21:50 +00:00
1477 changed files with 10685 additions and 100099 deletions

View File

@@ -1,6 +1,7 @@
---
name: github-pr-reviewer
description: Reviews GitHub pull requests and provides feedback comments. This is the top skill to use for reviewing Pull Requests from GitHub.
description: Reviews GitHub pull requests and provides feedback comments.
disallowedTools: Write, Edit
---
# Review GitHub Pull Request
@@ -27,13 +28,12 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
- No need to highlight things that are already good.
## Output format:
- List specific comments for each file/line that needs attention.
- List specific comments for each file/line that needs attention
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
- Example output:
```
Overall assessment: request changes.
- [CRITICAL] sensor.py:143 - Memory leak
- [PROBLEM] data_processing.py:87 - Inefficient algorithm
- [SUGGESTION] test_init.py:45 - Improve x variable name
- [CRITICAL] Memory leak in homeassistant/components/sensor/my_sensor.py:143
- [PROBLEM] Inefficient algorithm in homeassistant/helpers/data_processing.py:87
- [SUGGESTION] Improve variable naming in homeassistant/helpers/config_validation.py:45
```
- Make sure to include the file and line number when possible in the bullet points.

View File

@@ -186,11 +186,15 @@ If `CHANGE_TYPE` IS "Breaking change" or "Deprecation", keep the `## Breaking ch
## Step 10: Push Branch and Create PR
Push the branch with upstream tracking, and create a PR against `home-assistant/core` with the generated title and body:
```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" \
--draft \
--title "TITLE_HERE" \
--body "$(cat <<'EOF'

View File

@@ -1,5 +1,5 @@
---
name: ha-integration-knowledge
name: Home Assistant Integration knowledge
description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference.
---
@@ -12,10 +12,6 @@ description: Everything you need to know to build, test and review Home Assistan
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely.
- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast.
- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining.
- "potato" is a forbidden word for an integration and should never be used.
The following platforms have extra guidelines:
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection

View File

@@ -32,10 +32,7 @@ Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) ov
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
# Skills
- ha-integration-knowledge: .claude/skills/ha-integration-knowledge/SKILL.md
- Home Assistant Integration knowledge: .claude/skills/integrations/SKILL.md

55
.github/renovate.json vendored
View File

@@ -6,7 +6,6 @@
"pep621",
"pip_requirements",
"pre-commit",
"regex",
"homeassistant-manifest"
],
@@ -27,16 +26,6 @@
]
},
"regexManagers": [
{
"description": "Update ruff required-version in pyproject.toml",
"managerFilePatterns": ["/^pyproject\\.toml$/"],
"matchStrings": ["required-version = \">=(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ruff",
"datasourceTemplate": "pypi"
}
],
"minimumReleaseAge": "7 days",
"prConcurrentLimit": 10,
"prHourlyLimit": 2,
@@ -89,50 +78,6 @@
"enabled": true,
"labels": ["dependency", "core"]
},
{
"description": "Common Python utilities (allowlisted)",
"matchPackageNames": [
"astral",
"atomicwrites-homeassistant",
"audioop-lts",
"awesomeversion",
"bcrypt",
"ciso8601",
"cronsim",
"defusedxml",
"fnv-hash-fast",
"getmac",
"ical",
"ifaddr",
"lru-dict",
"mutagen",
"propcache",
"pyserial",
"python-slugify",
"PyTurboJPEG",
"securetar",
"standard-aifc",
"standard-telnetlib",
"ulid-transform",
"url-normalize",
"xmltodict"
],
"enabled": true,
"labels": ["dependency"]
},
{
"description": "Home Assistant ecosystem packages (core-maintained, no cooldown)",
"matchPackageNames": [
"hassil",
"home-assistant-bluetooth",
"home-assistant-frontend",
"home-assistant-intents",
"infrared-protocols"
],
"enabled": true,
"minimumReleaseAge": null,
"labels": ["dependency", "core"]
},
{
"description": "Test dependencies (allowlisted)",
"matchPackageNames": [

View File

@@ -76,7 +76,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: translations
path: translations.tar.gz
@@ -530,7 +530,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -543,7 +543,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile

View File

@@ -282,7 +282,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@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -303,7 +303,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
with:
extra-args: --all-files zizmor
@@ -366,7 +366,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
key: >-
@@ -374,7 +374,7 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -386,7 +386,7 @@ jobs:
env.HA_SHORT_VERSION }}-
- name: Check if apt cache exists
id: cache-apt-check
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: |
@@ -432,7 +432,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -458,7 +458,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -486,7 +486,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -517,7 +517,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -554,7 +554,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -645,7 +645,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -659,7 +659,7 @@ jobs:
. venv/bin/activate
python -m script.licenses extract --output-file=licenses-${PYTHON_VERSION}.json
- name: Upload licenses
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
path: licenses-${{ matrix.python-version }}.json
@@ -696,7 +696,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -749,7 +749,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -806,7 +806,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -814,7 +814,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: .mypy_cache
key: >-
@@ -856,7 +856,7 @@ jobs:
- base
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -889,7 +889,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -903,7 +903,7 @@ jobs:
. venv/bin/activate
python -m script.split_tests ${TEST_GROUP_COUNT} tests
- name: Upload pytest_buckets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pytest_buckets
path: pytest_buckets.txt
@@ -932,7 +932,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -966,7 +966,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -1022,14 +1022,14 @@ jobs:
2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1042,7 +1042,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }}
path: junit.xml
@@ -1084,7 +1084,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1119,7 +1119,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -1181,7 +1181,7 @@ jobs:
2>&1 | tee pytest-${PYTHON_VERSION}-${mariadb}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1189,7 +1189,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1203,7 +1203,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-results-mariadb-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1242,7 +1242,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1279,7 +1279,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -1342,7 +1342,7 @@ jobs:
2>&1 | tee pytest-${PYTHON_VERSION}-${postgresql}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1350,7 +1350,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1364,7 +1364,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-results-postgres-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1425,7 +1425,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1459,7 +1459,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -1518,14 +1518,14 @@ jobs:
2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1538,7 +1538,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }}
path: junit.xml

View File

@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
category: "/language:python"

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Check if integration label was added and extract details
id: extract
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
// Debug: Log the event payload
@@ -118,7 +118,7 @@ jobs:
- name: Fetch similar issues
id: fetch_similar
if: steps.extract.outputs.should_continue == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }}
CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }}
@@ -285,7 +285,7 @@ jobs:
- name: Post duplicate detection results
id: post_results
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
AI_RESPONSE: ${{ steps.ai_detection.outputs.response }}
SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }}

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Check issue language
id: detect_language
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
@@ -95,7 +95,7 @@ jobs:
- name: Process non-English issues
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }}
ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }}

View File

@@ -22,7 +22,7 @@ jobs:
|| github.event.issue.type.name == 'Opportunity'
steps:
- name: Add no-stale label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.issues.addLabels({
@@ -42,7 +42,7 @@ jobs:
if: github.event.issue.type.name == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const issueAuthor = context.payload.issue.user.login;

View File

@@ -74,7 +74,7 @@ jobs:
) > .env_file
- name: Upload env_file
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: env_file
path: ./.env_file
@@ -82,7 +82,7 @@ jobs:
overwrite: true
- name: Upload requirements_diff
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: requirements_diff
path: ./requirements_diff.txt
@@ -94,7 +94,7 @@ jobs:
python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.10
rev: v0.15.1
hooks:
- id: ruff-check
args:
@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
rev: v1.23.1
hooks:
- id: zizmor
args:

View File

@@ -46,7 +46,6 @@ homeassistant.components.accuweather.*
homeassistant.components.acer_projector.*
homeassistant.components.acmeda.*
homeassistant.components.actiontec.*
homeassistant.components.actron_air.*
homeassistant.components.adax.*
homeassistant.components.adguard.*
homeassistant.components.aftership.*
@@ -179,7 +178,6 @@ homeassistant.components.dropbox.*
homeassistant.components.droplet.*
homeassistant.components.dsmr.*
homeassistant.components.duckdns.*
homeassistant.components.duco.*
homeassistant.components.dunehd.*
homeassistant.components.duotecno.*
homeassistant.components.easyenergy.*
@@ -224,7 +222,6 @@ homeassistant.components.fronius.*
homeassistant.components.frontend.*
homeassistant.components.fujitsu_fglair.*
homeassistant.components.fully_kiosk.*
homeassistant.components.fumis.*
homeassistant.components.fyta.*
homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.*
@@ -555,7 +552,6 @@ homeassistant.components.tcp.*
homeassistant.components.technove.*
homeassistant.components.tedee.*
homeassistant.components.telegram_bot.*
homeassistant.components.teleinfo.*
homeassistant.components.teslemetry.*
homeassistant.components.text.*
homeassistant.components.thethingsnetwork.*

View File

@@ -22,6 +22,3 @@ Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) ov
## Good practices
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.

22
CODEOWNERS generated
View File

@@ -362,8 +362,6 @@ CLAUDE.md @home-assistant/core
/tests/components/deluge/ @tkdrob
/homeassistant/components/demo/ @home-assistant/core
/tests/components/demo/ @home-assistant/core
/homeassistant/components/denon_rs232/ @balloob
/tests/components/denon_rs232/ @balloob
/homeassistant/components/denonavr/ @ol-iver @starkillerOG
/tests/components/denonavr/ @ol-iver @starkillerOG
/homeassistant/components/derivative/ @afaucogney @karwosts
@@ -400,8 +398,6 @@ CLAUDE.md @home-assistant/core
/tests/components/dnsip/ @gjohansson-ST
/homeassistant/components/door/ @home-assistant/core
/tests/components/door/ @home-assistant/core
/homeassistant/components/doorbell/ @home-assistant/core
/tests/components/doorbell/ @home-assistant/core
/homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket
/tests/components/doorbird/ @oblogic7 @bdraco @flacjacket
/homeassistant/components/dormakaba_dkey/ @emontnemery
@@ -432,8 +428,6 @@ CLAUDE.md @home-assistant/core
/tests/components/dynalite/ @ziv1234
/homeassistant/components/eafm/ @Jc2k
/tests/components/eafm/ @Jc2k
/homeassistant/components/earn_e_p1/ @Miggets7
/tests/components/earn_e_p1/ @Miggets7
/homeassistant/components/easyenergy/ @klaasnicolaas
/tests/components/easyenergy/ @klaasnicolaas
/homeassistant/components/ecoforest/ @pjanuario
@@ -594,8 +588,6 @@ CLAUDE.md @home-assistant/core
/tests/components/fujitsu_fglair/ @crevetor
/homeassistant/components/fully_kiosk/ @cgarwood
/tests/components/fully_kiosk/ @cgarwood
/homeassistant/components/fumis/ @frenck
/tests/components/fumis/ @frenck
/homeassistant/components/fyta/ @dontinelli
/tests/components/fyta/ @dontinelli
/homeassistant/components/garage_door/ @home-assistant/core
@@ -906,8 +898,8 @@ CLAUDE.md @home-assistant/core
/tests/components/jewish_calendar/ @tsvi
/homeassistant/components/justnimbus/ @kvanzuijlen
/tests/components/justnimbus/ @kvanzuijlen
/homeassistant/components/jvc_projector/ @SteveEasley
/tests/components/jvc_projector/ @SteveEasley
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
/tests/components/jvc_projector/ @SteveEasley @msavazzi
/homeassistant/components/kaiterra/ @Michsior14
/homeassistant/components/kaleidescape/ @SteveEasley
/tests/components/kaleidescape/ @SteveEasley
@@ -920,8 +912,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/keyboard_remote/ @bendavid @lanrat
/homeassistant/components/keymitt_ble/ @spycle
/tests/components/keymitt_ble/ @spycle
/homeassistant/components/kiosker/ @Claeysson
/tests/components/kiosker/ @Claeysson
/homeassistant/components/kitchen_sink/ @home-assistant/core
/tests/components/kitchen_sink/ @home-assistant/core
/homeassistant/components/kmtronic/ @dgomes
@@ -1255,8 +1245,6 @@ CLAUDE.md @home-assistant/core
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek @ab3lson
/tests/components/open_router/ @joostlek @ab3lson
/homeassistant/components/openai_conversation/ @Shulyaka
/tests/components/openai_conversation/ @Shulyaka
/homeassistant/components/opendisplay/ @g4bri3lDev
/tests/components/opendisplay/ @g4bri3lDev
/homeassistant/components/openerz/ @misialq
@@ -1738,8 +1726,6 @@ CLAUDE.md @home-assistant/core
/tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/telegram_bot/ @hanwg
/tests/components/telegram_bot/ @hanwg
/homeassistant/components/teleinfo/ @esciara
/tests/components/teleinfo/ @esciara
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/teltonika/ @karlbeecken
@@ -1995,8 +1981,8 @@ CLAUDE.md @home-assistant/core
/tests/components/wsdot/ @ucodery
/homeassistant/components/wyoming/ @synesthesiam
/tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @tr4nt0r
/tests/components/xbox/ @tr4nt0r
/homeassistant/components/xbox/ @hunterjm @tr4nt0r
/tests/components/xbox/ @hunterjm @tr4nt0r
/homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi
/tests/components/xiaomi_aqara/ @danielhiversen @syssi
/homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79

20
Dockerfile generated
View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
@@ -20,22 +19,25 @@ ENV \
UV_SYSTEM_PYTHON=true \
UV_NO_CACHE=true
WORKDIR /usr/src
# Home Assistant S6-Overlay
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
## Setup Home Assistant Core dependencies
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv at the version pinned in the requirements file
&& pip3 install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{print $2}' homeassistant/requirements.txt)" \
&& uv pip install \
# Install uv
&& pip3 install uv==0.11.1
WORKDIR /usr/src
## Setup Home Assistant Core dependencies
COPY requirements.txt homeassistant/
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
RUN \
uv pip install \
--no-build \
-r homeassistant/requirements.txt
@@ -49,7 +51,7 @@ RUN \
-r homeassistant/requirements_all.txt
## Setup Home Assistant Core
COPY --parents LICENSE* README* homeassistant/ pyproject.toml homeassistant/
COPY . homeassistant/
RUN \
uv pip install \
-e ./homeassistant \

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
FROM mcr.microsoft.com/vscode/devcontainers/base:debian
SHELL ["/bin/bash", "-o", "pipefail", "-c"]

View File

@@ -7,31 +7,23 @@ to speed up the process.
from __future__ import annotations
from collections.abc import Container, Iterable, Sequence
from datetime import timedelta
from functools import lru_cache
from typing import Any, override
from functools import lru_cache, partial
from typing import Any
from jwt import DecodeError, PyJWK, PyJWS, PyJWT
from jwt.algorithms import AllowedPublicKeys
from jwt.types import Options
from jwt import DecodeError, PyJWS, PyJWT
from homeassistant.util.json import json_loads
JWT_TOKEN_CACHE_SIZE = 16
MAX_TOKEN_SIZE = 8192
_NO_VERIFY_OPTIONS = Options(
verify_signature=False,
verify_exp=False,
verify_nbf=False,
verify_iat=False,
verify_aud=False,
verify_iss=False,
verify_sub=False,
verify_jti=False,
require=[],
)
_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss", "sub", "jti")
_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | {
"require": []
}
_NO_VERIFY_OPTIONS = {f"verify_{key}": False for key in _VERIFY_KEYS}
class _PyJWSWithLoadCache(PyJWS):
@@ -46,6 +38,9 @@ class _PyJWSWithLoadCache(PyJWS):
return super()._load(jwt)
_jws = _PyJWSWithLoadCache()
@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)
def _decode_payload(json_payload: str) -> dict[str, Any]:
"""Decode the payload from a JWS dictionary."""
@@ -61,12 +56,21 @@ def _decode_payload(json_payload: str) -> dict[str, Any]:
class _PyJWTWithVerify(PyJWT):
"""PyJWT with a fast decode implementation."""
def __init__(self) -> None:
"""Initialize the PyJWT instance."""
# We require exp and iat claims to be present
super().__init__(Options(require=["exp", "iat"]))
# Override the _jws instance with our cached version
self._jws = _PyJWSWithLoadCache()
def decode_payload(
self, jwt: str, key: str, options: dict[str, Any], algorithms: list[str]
) -> dict[str, Any]:
"""Decode a JWT's payload."""
if len(jwt) > MAX_TOKEN_SIZE:
# Avoid caching impossible tokens
raise DecodeError("Token too large")
return _decode_payload(
_jws.decode_complete(
jwt=jwt,
key=key,
algorithms=algorithms,
options=options,
)["payload"]
)
def verify_and_decode(
self,
@@ -75,70 +79,37 @@ class _PyJWTWithVerify(PyJWT):
algorithms: list[str],
issuer: str | None = None,
leeway: float | timedelta = 0,
options: Options | None = None,
options: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Verify a JWT's signature and claims."""
return self.decode(
merged_options = {**_VERIFY_OPTIONS, **(options or {})}
payload = self.decode_payload(
jwt=jwt,
key=key,
options=merged_options,
algorithms=algorithms,
)
# These should never be missing since we verify them
# but this is an additional safeguard to make sure
# nothing slips through.
assert "exp" in payload, "exp claim is required"
assert "iat" in payload, "iat claim is required"
self._validate_claims(
payload=payload,
options=merged_options,
issuer=issuer,
leeway=leeway,
options=options,
)
@override
def decode(
self,
jwt: str | bytes,
key: AllowedPublicKeys | PyJWK | str | bytes = "",
algorithms: Sequence[str] | None = None,
options: Options | None = None,
verify: bool | None = None,
detached_payload: bytes | None = None,
audience: str | Iterable[str] | None = None,
subject: str | None = None,
issuer: str | Container[str] | None = None,
leeway: float | timedelta = 0,
**kwargs: Any,
) -> dict[str, Any]:
"""Decode a JWT, verifying the signature and claims."""
if len(jwt) > MAX_TOKEN_SIZE:
# Avoid caching impossible tokens
raise DecodeError("Token too large")
return super().decode(
jwt=jwt,
key=key,
algorithms=algorithms,
options=options,
verify=verify,
detached_payload=detached_payload,
audience=audience,
subject=subject,
issuer=issuer,
leeway=leeway,
**kwargs,
)
@override
def _decode_payload(self, decoded: dict[str, Any]) -> dict[str, Any]:
return _decode_payload(decoded["payload"])
return payload
_jwt = _PyJWTWithVerify()
verify_and_decode = _jwt.verify_and_decode
@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)
def unverified_hs256_token_decode(jwt: str) -> dict[str, Any]:
"""Decode a JWT without verifying the signature."""
return _jwt.decode(
jwt=jwt,
key="",
algorithms=["HS256"],
options=_NO_VERIFY_OPTIONS,
unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)(
partial(
_jwt.decode_payload, key="", algorithms=["HS256"], options=_NO_VERIFY_OPTIONS
)
)
__all__ = [
"unverified_hs256_token_decode",

View File

@@ -1,5 +1,5 @@
{
"domain": "denon",
"name": "Denon",
"integrations": ["denon", "denonavr", "denon_rs232", "heos"]
"integrations": ["denon", "denonavr", "heos"]
}

View File

@@ -30,7 +30,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_POLLING, DOMAIN, LOGGER
from .const import CONF_POLLING, DOMAIN, DOMAIN_DATA, LOGGER
from .services import async_setup_services
ATTR_DEVICE_NAME = "device_name"
@@ -67,16 +67,13 @@ class AbodeSystem:
logout_listener: CALLBACK_TYPE | None = None
type AbodeConfigEntry = ConfigEntry[AbodeSystem]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Abode component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Abode integration from a config entry."""
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
@@ -102,54 +99,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> boo
except (AbodeException, ConnectTimeout, HTTPError) as ex:
raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex
entry.runtime_data = AbodeSystem(abode, polling)
hass.data[DOMAIN_DATA] = AbodeSystem(abode, polling)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await setup_hass_events(hass, entry)
await hass.async_add_executor_job(setup_abode_events, hass, entry)
await setup_hass_events(hass)
await hass.async_add_executor_job(setup_abode_events, hass)
return True
def _shutdown_client(abode: Abode) -> None:
"""Shutdown client."""
abode.events.stop()
abode.logout()
async def async_unload_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
await hass.async_add_executor_job(_shutdown_client, entry.runtime_data.abode)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.stop)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.logout)
if logout_listener := entry.runtime_data.logout_listener:
if logout_listener := hass.data[DOMAIN_DATA].logout_listener:
logout_listener()
hass.data.pop(DOMAIN_DATA)
return unload_ok
async def setup_hass_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
async def setup_hass_events(hass: HomeAssistant) -> None:
"""Home Assistant start and stop callbacks."""
def logout(event: Event) -> None:
"""Logout of Abode."""
if not entry.runtime_data.polling:
entry.runtime_data.abode.events.stop()
if not hass.data[DOMAIN_DATA].polling:
hass.data[DOMAIN_DATA].abode.events.stop()
entry.runtime_data.abode.logout()
hass.data[DOMAIN_DATA].abode.logout()
LOGGER.info("Logged out of Abode")
if not entry.runtime_data.polling:
await hass.async_add_executor_job(entry.runtime_data.abode.events.start)
if not hass.data[DOMAIN_DATA].polling:
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.start)
entry.runtime_data.logout_listener = hass.bus.async_listen_once(
hass.data[DOMAIN_DATA].logout_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, logout
)
def setup_abode_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
def setup_abode_events(hass: HomeAssistant) -> None:
"""Event callbacks."""
def event_callback(event: str, event_json: dict[str, str]) -> None:
@@ -186,6 +179,6 @@ def setup_abode_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
]
for event in events:
entry.runtime_data.abode.events.add_event_callback(
hass.data[DOMAIN_DATA].abode.events.add_event_callback(
event, partial(event_callback, event)
)

View File

@@ -9,20 +9,21 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode alarm control panel device."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
[AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))]
)

View File

@@ -10,21 +10,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode binary sensor devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
device_types = [
"connectivity",

View File

@@ -12,13 +12,14 @@ import requests
from requests.models import Response
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from . import AbodeConfigEntry, AbodeSystem
from .const import LOGGER
from . import AbodeSystem
from .const import DOMAIN_DATA, LOGGER
from .entity import AbodeDevice
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
@@ -26,11 +27,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode camera devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeCamera(data, device, timeline.CAPTURE_IMAGE)

View File

@@ -3,10 +3,17 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import AbodeSystem
LOGGER = logging.getLogger(__package__)
DOMAIN = "abode"
DOMAIN_DATA: HassKey[AbodeSystem] = HassKey(DOMAIN)
ATTRIBUTION = "Data provided by goabode.com"
CONF_POLLING = "polling"

View File

@@ -5,20 +5,21 @@ from typing import Any
from jaraco.abode.devices.cover import Cover
from homeassistant.components.cover import CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode cover devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeCover(data, device)

View File

@@ -7,7 +7,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from . import AbodeSystem
from .const import ATTRIBUTION, DOMAIN
from .const import ATTRIBUTION, DOMAIN, DOMAIN_DATA
class AbodeEntity(Entity):
@@ -29,7 +29,7 @@ class AbodeEntity(Entity):
self._update_connection_status,
)
self._data.entity_ids.add(self.entity_id)
self.hass.data[DOMAIN_DATA].entity_ids.add(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from Abode connection status updates."""

View File

@@ -16,20 +16,21 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode light devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeLight(data, device)

View File

@@ -5,20 +5,21 @@ from typing import Any
from jaraco.abode.devices.lock import Lock
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode lock devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeLock(data, device)

View File

@@ -14,11 +14,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry, AbodeSystem
from . import AbodeSystem
from .const import DOMAIN_DATA
from .entity import AbodeDevice
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
@@ -64,11 +66,11 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode sensor devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeSensor(data, device, description)

View File

@@ -2,21 +2,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from jaraco.abode.exceptions import Exception as AbodeException
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
from . import AbodeConfigEntry, AbodeSystem
from .const import DOMAIN, DOMAIN_DATA, LOGGER
ATTR_SETTING = "setting"
ATTR_VALUE = "value"
@@ -31,21 +25,13 @@ CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
def _get_abode_system(hass: HomeAssistant) -> AbodeSystem:
"""Return the Abode system for the loaded config entry."""
entries: list[AbodeConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN)
if not entries:
raise ServiceValidationError("Abode integration is not loaded")
return entries[0].runtime_data
def _change_setting(call: ServiceCall) -> None:
"""Change an Abode system setting."""
setting = call.data[ATTR_SETTING]
value = call.data[ATTR_VALUE]
try:
_get_abode_system(call.hass).abode.set_setting(setting, value)
call.hass.data[DOMAIN_DATA].abode.set_setting(setting, value)
except AbodeException as ex:
LOGGER.warning(ex)
@@ -56,7 +42,7 @@ def _capture_image(call: ServiceCall) -> None:
target_entities = [
entity_id
for entity_id in _get_abode_system(call.hass).entity_ids
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
if entity_id in entity_ids
]
@@ -71,7 +57,7 @@ def _trigger_automation(call: ServiceCall) -> None:
target_entities = [
entity_id
for entity_id in _get_abode_system(call.hass).entity_ids
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
if entity_id in entity_ids
]

View File

@@ -7,11 +7,12 @@ from typing import Any, cast
from jaraco.abode.devices.switch import Switch
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeAutomation, AbodeDevice
DEVICE_TYPES = ["switch", "valve"]
@@ -19,11 +20,11 @@ DEVICE_TYPES = ["switch", "valve"]
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode switch devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
entities: list[SwitchEntity] = [
AbodeSwitch(data, device)

View File

@@ -6,11 +6,10 @@ from typing import Final
from homeassistant.const import STATE_OFF, STATE_ON
CONF_READ_TIMEOUT: Final = "timeout"
CONF_WRITE_TIMEOUT: Final = "write_timeout"
DEFAULT_NAME: Final = "Acer Projector"
DEFAULT_READ_TIMEOUT: Final = 1
DEFAULT_TIMEOUT: Final = 1
DEFAULT_WRITE_TIMEOUT: Final = 1
ECO_MODE: Final = "ECO Mode"

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.4.1"]
"requirements": ["pyserial==3.5"]
}

View File

@@ -6,7 +6,7 @@ import logging
import re
from typing import Any
from serialx import Serial, SerialException
import serial
import voluptuous as vol
from homeassistant.components.switch import (
@@ -16,22 +16,21 @@ from homeassistant.components.switch import (
from homeassistant.const import (
CONF_FILENAME,
CONF_NAME,
CONF_TIMEOUT,
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
CMD_DICT,
CONF_READ_TIMEOUT,
CONF_WRITE_TIMEOUT,
DEFAULT_NAME,
DEFAULT_READ_TIMEOUT,
DEFAULT_TIMEOUT,
DEFAULT_WRITE_TIMEOUT,
ECO_MODE,
ICON,
@@ -46,7 +45,7 @@ PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_FILENAME): cv.isdevice,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_READ_TIMEOUT, default=DEFAULT_READ_TIMEOUT): cv.positive_int,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
vol.Optional(
CONF_WRITE_TIMEOUT, default=DEFAULT_WRITE_TIMEOUT
): cv.positive_int,
@@ -63,10 +62,10 @@ def setup_platform(
"""Connect with serial port and return Acer Projector."""
serial_port = config[CONF_FILENAME]
name = config[CONF_NAME]
read_timeout = config[CONF_READ_TIMEOUT]
timeout = config[CONF_TIMEOUT]
write_timeout = config[CONF_WRITE_TIMEOUT]
add_entities([AcerSwitch(serial_port, name, read_timeout, write_timeout)], True)
add_entities([AcerSwitch(serial_port, name, timeout, write_timeout)], True)
class AcerSwitch(SwitchEntity):
@@ -78,14 +77,14 @@ class AcerSwitch(SwitchEntity):
self,
serial_port: str,
name: str,
read_timeout: int,
timeout: int,
write_timeout: int,
) -> None:
"""Init of the Acer projector."""
self.serial = serial.Serial(
port=serial_port, timeout=timeout, write_timeout=write_timeout
)
self._serial_port = serial_port
self._read_timeout = read_timeout
self._write_timeout = write_timeout
self._attr_name = name
self._attributes = {
LAMP_HOURS: STATE_UNKNOWN,
@@ -95,26 +94,22 @@ class AcerSwitch(SwitchEntity):
def _write_read(self, msg: str) -> str:
"""Write to the projector and read the return."""
ret = ""
# Sometimes the projector won't answer for no reason or the projector
# was disconnected during runtime.
# This way the projector can be reconnected and will still work
try:
with Serial.from_url(
self._serial_port,
read_timeout=self._read_timeout,
write_timeout=self._write_timeout,
) as serial:
serial.write(msg.encode("utf-8"))
# Size is an experience value there is no real limit.
# AFAIK there is no limit and no end character so we will usually
# need to wait for timeout
return serial.read_until(size=20).decode("utf-8")
except (OSError, SerialException, TimeoutError) as exc:
raise HomeAssistantError(
f"Problem communicating with {self._serial_port}"
) from exc
if not self.serial.is_open:
self.serial.open()
self.serial.write(msg.encode("utf-8"))
# Size is an experience value there is no real limit.
# AFAIK there is no limit and no end character so we will usually
# need to wait for timeout
ret = self.serial.read_until(size=20).decode("utf-8")
except serial.SerialException:
_LOGGER.error("Problem communicating with %s", self._serial_port)
self.serial.close()
return ret
def _write_read_format(self, msg: str) -> str:
"""Write msg, obtain answer and format output."""

View File

@@ -15,10 +15,8 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity, actron_air_command
@@ -141,24 +139,20 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
@actron_air_command
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR[fan_mode]
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
@actron_air_command
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR[hvac_mode]
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode)
@actron_air_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temperature_missing",
)
await self._status.user_aircon_settings.set_temperature(temperature=temperature)
temp = kwargs.get(ATTR_TEMPERATURE)
await self._status.user_aircon_settings.set_temperature(temperature=temp)
class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
@@ -227,9 +221,4 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
@actron_air_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temperature_missing",
)
await self._zone.set_temperature(temperature=temperature)
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))

View File

@@ -23,7 +23,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
self._user_code: str = ""
self._verification_uri: str = ""
self._expires_minutes: str = "30"
self.login_task: asyncio.Task[None] | None = None
self.login_task: asyncio.Task | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -94,7 +94,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("Error getting user info: %s", err)
return self.async_abort(reason="oauth2_error")
unique_id = user_data.sub
unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id)
# Check if this is a reauth flow
@@ -107,7 +107,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data.email,
title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value},
)

View File

@@ -78,14 +78,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
translation_placeholders={"error": repr(err)},
) from err
status = self.api.state_manager.get_status(self.serial_number)
if status is None:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": "Status not available"},
)
self.status = status
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
return self.status

View File

@@ -24,7 +24,7 @@ def actron_air_command[_EntityT: ActronAirEntity, **_P](
"""
@wraps(func)
async def wrapper(self: _EntityT, /, *args: _P.args, **kwargs: _P.kwargs) -> None:
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap API calls with exception handling."""
try:
await func(self, *args, **kwargs)

View File

@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.3"]
"requirements": ["actron-neo-api==0.5.0"]
}

View File

@@ -69,4 +69,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: done
strict-typing: todo

View File

@@ -58,9 +58,6 @@
"setup_connection_error": {
"message": "Failed to connect to the Actron Air API"
},
"temperature_missing": {
"message": "Provide a temperature value when adjusting the climate entity."
},
"update_error": {
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
}

View File

@@ -25,7 +25,7 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
hass,
DOMAIN,
"AI generated images",
"AI Generated Images",
{IMAGE_DIR: str(media_dir)},
f"/{DOMAIN}",
)

View File

@@ -36,9 +36,7 @@ def _make_detected_condition(
) -> 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,
support_duration=True,
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
)
@@ -47,9 +45,7 @@ def _make_cleared_condition(
) -> 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,
support_duration=True,
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
)

View File

@@ -249,11 +249,6 @@
.condition_binary_common: &condition_binary_common
fields:
behavior: *condition_behavior
for:
required: true
default: 00:00:00
selector:
duration:
is_gas_detected:
<<: *condition_binary_common

View File

@@ -1,10 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold type"
},
"conditions": {
@@ -25,9 +23,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Carbon monoxide cleared"
@@ -37,9 +32,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Carbon monoxide detected"
@@ -61,9 +53,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Gas cleared"
@@ -73,9 +62,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Gas detected"
@@ -181,9 +167,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Smoke cleared"
@@ -193,9 +176,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Smoke detected"
@@ -269,9 +249,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -292,9 +269,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Carbon monoxide cleared"
@@ -305,9 +279,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -319,9 +290,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Carbon monoxide detected"
@@ -331,9 +299,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Gas cleared"
@@ -343,9 +308,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Gas detected"
@@ -365,9 +327,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -389,9 +348,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -413,9 +369,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -437,9 +390,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -461,9 +411,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -485,9 +432,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -509,9 +453,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -533,9 +474,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -547,9 +485,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Smoke cleared"
@@ -559,9 +494,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
}
},
"name": "Smoke detected"
@@ -581,9 +513,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -605,9 +534,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
@@ -629,9 +555,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}

View File

@@ -9,11 +9,6 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
# --- Unit lists for multi-unit pollutants ---
@@ -168,7 +163,6 @@
# Binary sensor detected/cleared trigger fields
.trigger_binary_fields: &trigger_binary_fields
behavior: *trigger_behavior
for: *trigger_for
# --- Binary sensor targets ---
@@ -300,7 +294,6 @@ co_crossed_threshold:
target: *target_co_sensor
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -327,7 +320,6 @@ co2_crossed_threshold:
target: *target_co2
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -352,7 +344,6 @@ pm1_crossed_threshold:
target: *target_pm1
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -377,7 +368,6 @@ pm25_crossed_threshold:
target: *target_pm25
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -402,7 +392,6 @@ pm4_crossed_threshold:
target: *target_pm4
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -427,7 +416,6 @@ pm10_crossed_threshold:
target: *target_pm10
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -454,7 +442,6 @@ ozone_crossed_threshold:
target: *target_ozone
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -483,7 +470,6 @@ voc_crossed_threshold:
target: *target_voc
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -512,7 +498,6 @@ voc_ratio_crossed_threshold:
target: *target_voc_ratio
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -541,7 +526,6 @@ no_crossed_threshold:
target: *target_no
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -570,7 +554,6 @@ no2_crossed_threshold:
target: *target_no2
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -597,7 +580,6 @@ n2o_crossed_threshold:
target: *target_n2o
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
@@ -624,7 +606,6 @@ so2_crossed_threshold:
target: *target_so2
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -4,7 +4,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR,
Condition,
EntityStateConditionBase,
make_entity_state_condition,
@@ -26,7 +25,6 @@ class EntityStateRequiredFeaturesCondition(EntityStateConditionBase):
"""State condition."""
_required_features: int
_schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain with the required features."""
@@ -84,11 +82,9 @@ CONDITIONS: dict[str, type[Condition]] = {
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"is_disarmed": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.DISARMED, support_duration=True
),
"is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
"is_triggered": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.TRIGGERED, support_duration=True
DOMAIN, AlarmControlPanelState.TRIGGERED
),
}

View File

@@ -1,9 +1,9 @@
.condition_common: &condition_common
target: &condition_common_target
target:
entity:
domain: alarm_control_panel
fields: &condition_common_fields
behavior: &condition_common_behavior
behavior:
required: true
default: any
selector:
@@ -13,20 +13,10 @@
- all
- any
.condition_common_for: &condition_common_for
target: *condition_common_target
fields: &condition_common_for_fields
behavior: *condition_common_behavior
for:
required: true
default: 00:00:00
selector:
duration:
is_armed: *condition_common
is_armed_away:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
@@ -34,7 +24,7 @@ is_armed_away:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
is_armed_home:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
@@ -42,7 +32,7 @@ is_armed_home:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
is_armed_night:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
@@ -50,13 +40,13 @@ is_armed_night:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
is_armed_vacation:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
is_disarmed: *condition_common_for
is_disarmed: *condition_common
is_triggered: *condition_common_for
is_triggered: *condition_common

View File

@@ -1,9 +1,7 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_armed": {
@@ -20,9 +18,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed away"
@@ -32,9 +27,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed home"
@@ -44,9 +36,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed night"
@@ -56,9 +45,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed vacation"
@@ -68,9 +54,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is disarmed"
@@ -80,9 +63,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is triggered"
@@ -254,9 +234,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm armed"
@@ -266,9 +243,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm armed away"
@@ -278,9 +252,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm armed home"
@@ -290,9 +261,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm armed night"
@@ -302,9 +270,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm armed vacation"
@@ -314,9 +279,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm disarmed"
@@ -326,9 +288,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::trigger_for_name%]"
}
},
"name": "Alarm triggered"

View File

@@ -13,11 +13,6 @@
- last
- any
translation_key: trigger_behavior
for:
required: true
default: 00:00:00
selector:
duration:
armed: *trigger_common

View File

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

View File

@@ -39,6 +39,7 @@ from homeassistant.helpers.typing import ConfigType
from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors
from .camera import STREAM_SOURCE_LIST
from .const import (
CAMERAS,
COMM_RETRIES,
COMM_TIMEOUT,
DATA_AMCREST,
@@ -358,7 +359,7 @@ def _start_event_monitor(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Amcrest IP Camera component."""
hass.data.setdefault(DATA_AMCREST, {DEVICES: {}})
hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []})
for device in config[DOMAIN]:
name: str = device[CONF_NAME]

View File

@@ -12,11 +12,13 @@ import aiohttp
from aiohttp import web
from amcrest import AmcrestError
from haffmpeg.camera import CameraMjpeg
import voluptuous as vol
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager
from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import (
async_aiohttp_proxy_stream,
async_aiohttp_proxy_web,
@@ -27,13 +29,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
ATTR_COLOR_BW,
CAMERA_WEB_SESSION_TIMEOUT,
CBW,
CAMERAS,
COMM_TIMEOUT,
DATA_AMCREST,
DEVICES,
MOV,
RESOLUTION_TO_STREAM,
SERVICE_UPDATE,
SNAPSHOT_TIMEOUT,
@@ -49,11 +49,65 @@ SCAN_INTERVAL = timedelta(seconds=15)
STREAM_SOURCE_LIST = ["snapshot", "mjpeg", "rtsp"]
_ATTR_PTZ_TT = "travel_time"
_ATTR_PTZ_MOV = "movement"
_MOV = [
"zoom_out",
"zoom_in",
"right",
"left",
"up",
"down",
"right_down",
"right_up",
"left_down",
"left_up",
]
_ZOOM_ACTIONS = ["ZoomWide", "ZoomTele"]
_MOVE_1_ACTIONS = ["Right", "Left", "Up", "Down"]
_MOVE_2_ACTIONS = ["RightDown", "RightUp", "LeftDown", "LeftUp"]
_ACTION = _ZOOM_ACTIONS + _MOVE_1_ACTIONS + _MOVE_2_ACTIONS
_DEFAULT_TT = 0.2
_ATTR_PRESET = "preset"
_ATTR_COLOR_BW = "color_bw"
_CBW_COLOR = "color"
_CBW_AUTO = "auto"
_CBW_BW = "bw"
_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW]
_SRV_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids})
_SRV_GOTO_SCHEMA = _SRV_SCHEMA.extend(
{vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))}
)
_SRV_CBW_SCHEMA = _SRV_SCHEMA.extend({vol.Required(_ATTR_COLOR_BW): vol.In(_CBW)})
_SRV_PTZ_SCHEMA = _SRV_SCHEMA.extend(
{
vol.Required(_ATTR_PTZ_MOV): vol.In(_MOV),
vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float,
}
)
CAMERA_SERVICES = {
"enable_recording": (_SRV_SCHEMA, "async_enable_recording", ()),
"disable_recording": (_SRV_SCHEMA, "async_disable_recording", ()),
"enable_audio": (_SRV_SCHEMA, "async_enable_audio", ()),
"disable_audio": (_SRV_SCHEMA, "async_disable_audio", ()),
"enable_motion_recording": (_SRV_SCHEMA, "async_enable_motion_recording", ()),
"disable_motion_recording": (_SRV_SCHEMA, "async_disable_motion_recording", ()),
"goto_preset": (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)),
"set_color_bw": (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)),
"start_tour": (_SRV_SCHEMA, "async_start_tour", ()),
"stop_tour": (_SRV_SCHEMA, "async_stop_tour", ()),
"ptz_control": (
_SRV_PTZ_SCHEMA,
"async_ptz_control",
(_ATTR_PTZ_MOV, _ATTR_PTZ_TT),
),
}
_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF}
@@ -221,7 +275,7 @@ class AmcrestCam(Camera):
self._motion_recording_enabled
)
if self._color_bw is not None:
attr[ATTR_COLOR_BW] = self._color_bw
attr[_ATTR_COLOR_BW] = self._color_bw
return attr
@property
@@ -268,7 +322,15 @@ class AmcrestCam(Camera):
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self) -> None:
"""Subscribe to signals."""
"""Subscribe to signals and add camera to list."""
self._unsub_dispatcher.extend(
async_dispatcher_connect(
self.hass,
service_signal(service, self.entity_id),
getattr(self, callback_name),
)
for service, (_, callback_name, _) in CAMERA_SERVICES.items()
)
self._unsub_dispatcher.append(
async_dispatcher_connect(
self.hass,
@@ -276,9 +338,11 @@ class AmcrestCam(Camera):
self.async_on_demand_update,
)
)
self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect from signals."""
"""Remove camera from list and disconnect from signals."""
self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id)
for unsub_dispatcher in self._unsub_dispatcher:
unsub_dispatcher()
@@ -392,7 +456,7 @@ class AmcrestCam(Camera):
async def async_ptz_control(self, movement: str, travel_time: float) -> None:
"""Move or zoom camera in specified direction."""
code = _ACTION[MOV.index(movement)]
code = _ACTION[_MOV.index(movement)]
kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0}
if code in _MOVE_1_ACTIONS:
@@ -549,10 +613,10 @@ class AmcrestCam(Camera):
)
async def _async_get_color_mode(self) -> str:
return CBW[await self._api.async_day_night_color]
return _CBW[await self._api.async_day_night_color]
async def _async_set_color_mode(self, cbw: str) -> None:
await self._api.async_set_day_night_color(CBW.index(cbw), channel=0)
await self._api.async_set_day_night_color(_CBW.index(cbw), channel=0)
async def _async_set_color_bw(self, cbw: str) -> None:
"""Set camera color mode."""

View File

@@ -2,6 +2,7 @@
DOMAIN = "amcrest"
DATA_AMCREST = DOMAIN
CAMERAS = "cameras"
DEVICES = "devices"
BINARY_SENSOR_SCAN_INTERVAL_SECS = 5
@@ -16,18 +17,3 @@ SERVICE_UPDATE = "update"
RESOLUTION_LIST = {"high": 0, "low": 1}
RESOLUTION_TO_STREAM = {0: "Main", 1: "Extra"}
ATTR_COLOR_BW = "color_bw"
CBW = ["color", "auto", "bw"]
MOV = [
"zoom_out",
"zoom_in",
"right",
"left",
"up",
"down",
"right_down",
"right_up",
"left_down",
"left_up",
]

View File

@@ -1,67 +1,62 @@
"""Services for Amcrest IP cameras."""
"""Support for Amcrest IP cameras."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_CONTROL
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import Unauthorized, UnknownUser
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import async_extract_entity_ids
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_COLOR_BW, CBW, DOMAIN, MOV
_ATTR_PRESET = "preset"
_ATTR_PTZ_MOV = "movement"
_ATTR_PTZ_TT = "travel_time"
_DEFAULT_TT = 0.2
from .camera import CAMERA_SERVICES
from .const import CAMERAS, DATA_AMCREST, DOMAIN
from .helpers import service_signal
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the Amcrest IP Camera services."""
for service_name, func in (
("enable_recording", "async_enable_recording"),
("disable_recording", "async_disable_recording"),
("enable_audio", "async_enable_audio"),
("disable_audio", "async_disable_audio"),
("enable_motion_recording", "async_enable_motion_recording"),
("disable_motion_recording", "async_disable_motion_recording"),
("start_tour", "async_start_tour"),
("stop_tour", "async_stop_tour"),
):
service.async_register_platform_entity_service(
hass,
DOMAIN,
service_name,
entity_domain=CAMERA_DOMAIN,
schema=None,
func=func,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"goto_preset",
entity_domain=CAMERA_DOMAIN,
schema={vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))},
func="async_goto_preset",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_color_bw",
entity_domain=CAMERA_DOMAIN,
schema={vol.Required(ATTR_COLOR_BW): vol.In(CBW)},
func="async_set_color_bw",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"ptz_control",
entity_domain=CAMERA_DOMAIN,
schema={
vol.Required(_ATTR_PTZ_MOV): vol.In(MOV),
vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float,
},
func="async_ptz_control",
)
def have_permission(user: User | None, entity_id: str) -> bool:
return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL)
async def async_extract_from_service(call: ServiceCall) -> list[str]:
if call.context.user_id:
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(context=call.context)
else:
user = None
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
# Return all entity_ids user has permission to control.
return [
entity_id
for entity_id in hass.data[DATA_AMCREST][CAMERAS]
if have_permission(user, entity_id)
]
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
return []
call_ids = await async_extract_entity_ids(call)
entity_ids = []
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
if entity_id not in call_ids:
continue
if not have_permission(user, entity_id):
raise Unauthorized(
context=call.context, entity_id=entity_id, permission=POLICY_CONTROL
)
entity_ids.append(entity_id)
return entity_ids
async def async_service_handler(call: ServiceCall) -> None:
args = [call.data[arg] for arg in CAMERA_SERVICES[call.service][2]]
for entity_id in await async_extract_from_service(call):
async_dispatcher_send(hass, service_signal(call.service, entity_id), *args)
for service, params in CAMERA_SERVICES.items():
hass.services.async_register(DOMAIN, service, async_service_handler, params[0])

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from anthropic.resources.messages.messages import DEPRECATED_MODELS
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
@@ -15,7 +13,13 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_CHAT_MODEL, DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
from .const import (
CONF_CHAT_MODEL,
DEFAULT_CONVERSATION_NAME,
DEPRECATED_MODELS,
DOMAIN,
LOGGER,
)
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
@@ -40,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
entry.async_on_unload(entry.add_update_listener(async_update_options))
for subentry in entry.subentries.values():
if (model := subentry.data.get(CONF_CHAT_MODEL)) and model in DEPRECATED_MODELS:
if (model := subentry.data.get(CONF_CHAT_MODEL)) and model.startswith(
tuple(DEPRECATED_MODELS)
):
ir.async_create_issue(
hass,
DOMAIN,
@@ -230,19 +236,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
)
hass.config_entries.async_update_entry(entry, minor_version=3)
if entry.version == 2 and entry.minor_version == 3:
# Remove Temperature parameter
CONF_TEMPERATURE = "temperature"
for subentry in entry.subentries.values():
data = subentry.data.copy()
if CONF_TEMPERATURE not in data:
continue
data.pop(CONF_TEMPERATURE, None)
hass.config_entries.async_update_subentry(entry, subentry, data=data)
hass.config_entries.async_update_entry(entry, minor_version=4)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)

View File

@@ -29,7 +29,6 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
NumberSelector,
@@ -43,12 +42,14 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.typing import VolDictType
from .const import (
CODE_EXECUTION_UNSUPPORTED_MODELS,
CONF_CHAT_MODEL,
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_PROMPT_CACHING,
CONF_RECOMMENDED,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
@@ -63,8 +64,10 @@ from .const import (
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
TOOL_SEARCH_UNSUPPORTED_MODELS,
WEB_SEARCH_UNSUPPORTED_MODELS,
PromptCaching,
)
from .coordinator import model_alias
@@ -106,7 +109,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anthropic."""
VERSION = 2
MINOR_VERSION = 4
MINOR_VERSION = 3
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -321,6 +324,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
): SelectSelector(
SelectSelectorConfig(options=self._get_model_list(), custom_value=True)
),
vol.Optional(
CONF_MAX_TOKENS,
default=DEFAULT[CONF_MAX_TOKENS],
): int,
vol.Optional(
CONF_TEMPERATURE,
default=DEFAULT[CONF_TEMPERATURE],
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_PROMPT_CACHING,
default=DEFAULT[CONF_PROMPT_CACHING],
@@ -373,59 +384,30 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"""Manage model-specific options."""
errors: dict[str, str] = {}
step_schema: VolDictType = {
vol.Optional(
CONF_MAX_TOKENS,
default=DEFAULT[CONF_MAX_TOKENS],
): vol.All(
NumberSelector(
NumberSelectorConfig(min=0, max=self.model_info.max_tokens)
),
vol.Coerce(int),
)
if self.model_info.max_tokens
else cv.positive_int,
}
step_schema: VolDictType = {}
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.supported
and not self.model_info.capabilities.thinking.types.adaptive.supported
model = self.options[CONF_CHAT_MODEL]
if not model.startswith(tuple(NON_THINKING_MODELS)) and model.startswith(
tuple(NON_ADAPTIVE_THINKING_MODELS)
):
step_schema[
vol.Optional(
CONF_THINKING_BUDGET, default=DEFAULT[CONF_THINKING_BUDGET]
)
] = (
vol.All(
NumberSelector(
NumberSelectorConfig(min=0, max=self.model_info.max_tokens)
),
vol.Coerce(int),
)
if self.model_info.max_tokens
else cv.positive_int
] = vol.All(
NumberSelector(
NumberSelectorConfig(
min=0,
max=self.options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
)
),
vol.Coerce(int),
)
else:
self.options.pop(CONF_THINKING_BUDGET, None)
if (
self.model_info.capabilities
and (effort_capability := self.model_info.capabilities.effort).supported
):
effort_options: list[str] = []
if self.model_info.capabilities.thinking.types.adaptive.supported:
effort_options.append("none")
if effort_capability.low.supported:
effort_options.append("low")
if effort_capability.medium.supported:
effort_options.append("medium")
if effort_capability.high.supported:
effort_options.append("high")
if effort_capability.xhigh and effort_capability.xhigh.supported:
effort_options.append("xhigh")
if effort_capability.max.supported:
effort_options.append("max")
if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)):
step_schema[
vol.Optional(
CONF_THINKING_EFFORT,
@@ -433,7 +415,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
)
] = SelectSelector(
SelectSelectorConfig(
options=effort_options,
options=["none", "low", "medium", "high", "max"],
translation_key=CONF_THINKING_EFFORT,
mode=SelectSelectorMode.DROPDOWN,
)
@@ -441,34 +423,43 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
else:
self.options.pop(CONF_THINKING_EFFORT, None)
step_schema.update(
{
if not model.startswith(tuple(CODE_EXECUTION_UNSUPPORTED_MODELS)):
step_schema[
vol.Optional(
CONF_CODE_EXECUTION,
default=DEFAULT[CONF_CODE_EXECUTION],
): bool,
vol.Optional(
CONF_WEB_SEARCH,
default=DEFAULT[CONF_WEB_SEARCH],
): bool,
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
): int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
): bool,
}
)
)
] = bool
else:
self.options.pop(CONF_CODE_EXECUTION, None)
if not model.startswith(tuple(WEB_SEARCH_UNSUPPORTED_MODELS)):
step_schema.update(
{
vol.Optional(
CONF_WEB_SEARCH,
default=DEFAULT[CONF_WEB_SEARCH],
): bool,
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
): int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
): bool,
}
)
else:
self.options.pop(CONF_WEB_SEARCH, None)
self.options.pop(CONF_WEB_SEARCH_MAX_USES, None)
self.options.pop(CONF_WEB_SEARCH_USER_LOCATION, None)
self.options.pop(CONF_WEB_SEARCH_CITY, None)
self.options.pop(CONF_WEB_SEARCH_REGION, None)
self.options.pop(CONF_WEB_SEARCH_COUNTRY, None)
self.options.pop(CONF_WEB_SEARCH_TIMEZONE, None)
model = self.options[CONF_CHAT_MODEL]
if not model.startswith(tuple(TOOL_SEARCH_UNSUPPORTED_MODELS)):
step_schema[
vol.Optional(
@@ -480,19 +471,9 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
self.options.pop(CONF_TOOL_SEARCH, None)
if not step_schema:
# Currently our schema is always present, but if one day it becomes empty,
# then the below line is needed to skip this step
user_input = {} # pragma: no cover
user_input = {}
if user_input is not None:
if (
CONF_THINKING_BUDGET in user_input
and user_input[CONF_THINKING_BUDGET] >= MIN_THINKING_BUDGET
and user_input[CONF_THINKING_BUDGET]
>= user_input.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS])
):
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
if user_input.get(CONF_WEB_SEARCH, DEFAULT[CONF_WEB_SEARCH]) and not errors:
if user_input.get(
CONF_WEB_SEARCH_USER_LOCATION,

View File

@@ -15,6 +15,7 @@ CONF_CHAT_MODEL = "chat_model"
CONF_CODE_EXECUTION = "code_execution"
CONF_MAX_TOKENS = "max_tokens"
CONF_PROMPT_CACHING = "prompt_caching"
CONF_TEMPERATURE = "temperature"
CONF_THINKING_BUDGET = "thinking_budget"
CONF_THINKING_EFFORT = "thinking_effort"
CONF_TOOL_SEARCH = "tool_search"
@@ -42,6 +43,7 @@ DEFAULT = {
CONF_CODE_EXECUTION: False,
CONF_MAX_TOKENS: 3000,
CONF_PROMPT_CACHING: PromptCaching.PROMPT.value,
CONF_TEMPERATURE: 1.0,
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
CONF_THINKING_EFFORT: "low",
CONF_TOOL_SEARCH: False,
@@ -50,6 +52,54 @@ DEFAULT = {
CONF_WEB_SEARCH_MAX_USES: 5,
}
NON_THINKING_MODELS = [
"claude-3-haiku",
]
NON_ADAPTIVE_THINKING_MODELS = [
"claude-opus-4-5",
"claude-sonnet-4-5",
"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",
]
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
]
WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
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",
]
TOOL_SEARCH_UNSUPPORTED_MODELS = [
"claude-3",
"claude-haiku",
]
DEPRECATED_MODELS = [
"claude-3",
]

View File

@@ -28,7 +28,9 @@ _model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
if model_id == "claude-3-haiku-20240307" or model_id.endswith("-preview"):
return model_id
if model_id[-2:-1] != "-":
model_id = model_id[:-9]
if _model_short_form.search(model_id):
return model_id + "-0"

View File

@@ -30,7 +30,6 @@ from anthropic.types import (
MessageDeltaUsage,
MessageParam,
MessageStreamEvent,
ModelInfo,
OutputConfigParam,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
@@ -98,6 +97,7 @@ from .const import (
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT_CACHING,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
@@ -112,6 +112,10 @@ from .const import (
DOMAIN,
LOGGER,
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
PromptCaching,
)
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
@@ -124,14 +128,10 @@ def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> ToolParam:
"""Format tool specification."""
unsupported_keys = {"oneOf", "anyOf", "allOf"}
schema = convert(tool.parameters, custom_serializer=custom_serializer)
schema = {k: v for k, v in schema.items() if k not in unsupported_keys}
return ToolParam(
name=tool.name,
description=tool.description or "",
input_schema=schema,
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
)
@@ -757,43 +757,33 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
):
model_args["cache_control"] = {"type": "ephemeral"}
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.types.adaptive.supported
):
if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)):
thinking_effort = options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
)
if thinking_effort != "none":
model_args["thinking"] = ThinkingConfigAdaptiveParam(
type="adaptive", display="summarized"
)
model_args["thinking"] = ThinkingConfigAdaptiveParam(type="adaptive")
model_args["output_config"] = OutputConfigParam(effort=thinking_effort)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, DEFAULT[CONF_TEMPERATURE]
)
else:
thinking_budget = options.get(
CONF_THINKING_BUDGET, DEFAULT[CONF_THINKING_BUDGET]
)
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.types.enabled.supported
not model.startswith(tuple(NON_THINKING_MODELS))
and thinking_budget >= MIN_THINKING_BUDGET
):
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", display="summarized", budget_tokens=thinking_budget
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
if (
self.model_info.capabilities
and self.model_info.capabilities.effort.supported
):
model_args["output_config"] = OutputConfigParam(
effort=options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
)
model_args["temperature"] = options.get(
CONF_TEMPERATURE, DEFAULT[CONF_TEMPERATURE]
)
tools: list[ToolUnionParam] = []
@@ -805,11 +795,9 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
if options.get(CONF_CODE_EXECUTION):
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options.get(CONF_WEB_SEARCH)
):
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_WEB_SEARCH):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
@@ -818,11 +806,9 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
)
if options.get(CONF_WEB_SEARCH):
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options.get(CONF_CODE_EXECUTION)
):
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_CODE_EXECUTION):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
@@ -860,17 +846,12 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
]
last_message["content"].extend( # type: ignore[union-attr]
await async_prepare_files_for_prompt(
self.hass,
self.model_info,
[(a.path, a.mime_type) for a in last_content.attachments],
self.hass, [(a.path, a.mime_type) for a in last_content.attachments]
)
)
if structure and structure_name:
if (
self.model_info.capabilities
and self.model_info.capabilities.structured_outputs.supported
):
if not model.startswith(tuple(UNSUPPORTED_STRUCTURED_OUTPUT_MODELS)):
# Native structured output for those models who support it.
structure_name = None
model_args.setdefault("output_config", OutputConfigParam())[
@@ -1011,7 +992,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
async def async_prepare_files_for_prompt(
hass: HomeAssistant, model_info: ModelInfo, files: list[tuple[Path, str | None]]
hass: HomeAssistant, files: list[tuple[Path, str | None]]
) -> Iterable[ImageBlockParam | DocumentBlockParam]:
"""Append files to a prompt.
@@ -1032,26 +1013,13 @@ async def async_prepare_files_for_prompt(
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"))
or not model_info.capabilities
or (
mime_type.startswith("image/")
and not model_info.capabilities.image_input.supported
)
or (
mime_type.startswith("application/pdf")
and not model_info.capabilities.pdf_input.supported
)
):
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",
"model": model_info.display_name,
},
)
if mime_type == "image/jpg":

View File

@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.96.0"]
"requirements": ["anthropic==0.92.0"]
}

View File

@@ -6,7 +6,6 @@ from collections.abc import Iterator
from typing import TYPE_CHECKING
import anthropic
from anthropic.resources.messages.messages import DEPRECATED_MODELS
import voluptuous as vol
from homeassistant import data_entry_flow
@@ -20,7 +19,7 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
)
from .const import CONF_CHAT_MODEL, DOMAIN
from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN
from .coordinator import model_alias
if TYPE_CHECKING:
@@ -64,7 +63,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
model_list = [
model_option
for model_option in await self.get_model_list(client)
if model_option["value"] not in DEPRECATED_MODELS
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
]
self._model_list_cache[entry.entry_id] = model_list
@@ -106,7 +105,6 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
"model": model,
"subentry_name": subentry.title,
"subentry_type": self._format_subentry_type(subentry.subentry_type),
"retirement_date": DEPRECATED_MODELS[model],
},
)
@@ -133,7 +131,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
continue
for subentry in entry.subentries.values():
model = subentry.data.get(CONF_CHAT_MODEL)
if model and model in DEPRECATED_MODELS:
if model and model.startswith(tuple(DEPRECATED_MODELS)):
yield entry.entry_id, subentry.subentry_id
async def _async_next_target(
@@ -160,7 +158,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
continue
model = subentry.data.get(CONF_CHAT_MODEL)
if not model or model not in DEPRECATED_MODELS:
if not model or not model.startswith(tuple(DEPRECATED_MODELS)):
continue
self._current_entry_id = entry_id

View File

@@ -40,8 +40,7 @@
"entry_type": "AI task",
"error": {
"api_error": "[%key:component::anthropic::config_subentries::conversation::error::api_error%]",
"model_not_found": "[%key:component::anthropic::config_subentries::conversation::error::model_not_found%]",
"thinking_budget_too_large": "[%key:component::anthropic::config_subentries::conversation::error::thinking_budget_too_large%]"
"model_not_found": "[%key:component::anthropic::config_subentries::conversation::error::model_not_found%]"
},
"initiate_flow": {
"reconfigure": "Reconfigure AI task",
@@ -51,11 +50,13 @@
"advanced": {
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]",
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]",
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
},
"data_description": {
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::max_tokens%]",
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]",
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]"
},
@@ -75,7 +76,6 @@
"model": {
"data": {
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data::code_execution%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::model::data::max_tokens%]",
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]",
@@ -85,7 +85,6 @@
},
"data_description": {
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::code_execution%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::max_tokens%]",
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]",
@@ -105,8 +104,7 @@
"entry_type": "Conversation agent",
"error": {
"api_error": "Unable to get model info: {message}",
"model_not_found": "Model not found",
"thinking_budget_too_large": "Thinking budget must be less than the Maximum tokens."
"model_not_found": "Model not found"
},
"initiate_flow": {
"reconfigure": "Reconfigure conversation agent",
@@ -116,11 +114,13 @@
"advanced": {
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "Maximum tokens to return in response",
"prompt_caching": "Caching strategy",
"temperature": "Temperature"
},
"data_description": {
"chat_model": "The model to serve the responses.",
"max_tokens": "Limit the number of response tokens.",
"prompt_caching": "Optimize your API cost and response times based on your usage.",
"temperature": "Control the randomness of the response, trading off between creativity and coherence."
},
@@ -144,7 +144,6 @@
"model": {
"data": {
"code_execution": "Code execution",
"max_tokens": "Maximum tokens to return in response",
"thinking_budget": "Thinking budget",
"thinking_effort": "Thinking effort",
"tool_search": "Enable tool search tool",
@@ -154,7 +153,6 @@
},
"data_description": {
"code_execution": "Allow the model to execute code in a secure sandbox environment, enabling it to analyze data and perform complex calculations.",
"max_tokens": "Limit the number of response tokens.",
"thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.",
"thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency",
"tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context",
@@ -205,7 +203,7 @@
"message": "`{file_path}` does not exist."
},
"wrong_file_type": {
"message": "The {model} model does not support {mime_type} file types (for `{file_path}`)."
"message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF."
}
},
"issues": {
@@ -219,7 +217,7 @@
"data_description": {
"chat_model": "Select the new model to use."
},
"description": "You are updating {subentry_name} ({subentry_type}) in {entry_name}. The current model {model} is deprecated and will reach end-of-life on {retirement_date}. Select a supported model to continue.",
"description": "You are updating {subentry_name} ({subentry_type}) in {entry_name}. The current model {model} is deprecated. Select a supported model to continue.",
"title": "Update model"
}
}
@@ -241,8 +239,7 @@
"low": "[%key:common::state::low%]",
"max": "Max",
"medium": "[%key:common::state::medium%]",
"none": "None",
"xhigh": "X-High"
"none": "None"
}
}
}

View File

@@ -945,10 +945,7 @@ class PipelineRun:
try:
# Transcribe audio stream
stt_vad: VoiceCommandSegmenter | None = None
if (
self.audio_settings.is_vad_enabled
and self.stt_provider.audio_processing.requires_external_vad
):
if self.audio_settings.is_vad_enabled:
stt_vad = VoiceCommandSegmenter(
silence_seconds=self.audio_settings.silence_seconds
)

View File

@@ -7,17 +7,13 @@ from .const import DOMAIN
from .entity import AssistSatelliteState
CONDITIONS: dict[str, type[Condition]] = {
"is_idle": make_entity_state_condition(
DOMAIN, AssistSatelliteState.IDLE, support_duration=True
),
"is_listening": make_entity_state_condition(
DOMAIN, AssistSatelliteState.LISTENING, support_duration=True
),
"is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE),
"is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING),
"is_processing": make_entity_state_condition(
DOMAIN, AssistSatelliteState.PROCESSING, support_duration=True
DOMAIN, AssistSatelliteState.PROCESSING
),
"is_responding": make_entity_state_condition(
DOMAIN, AssistSatelliteState.RESPONDING, support_duration=True
DOMAIN, AssistSatelliteState.RESPONDING
),
}

View File

@@ -12,11 +12,6 @@
options:
- all
- any
for:
required: true
default: 00:00:00
selector:
duration:
is_idle: *condition_common
is_listening: *condition_common

View File

@@ -1,9 +1,7 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_idle": {
@@ -11,9 +9,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is idle"
@@ -23,9 +18,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is listening"
@@ -35,9 +27,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is processing"
@@ -47,9 +36,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is responding"
@@ -174,9 +160,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::trigger_for_name%]"
}
},
"name": "Satellite became idle"
@@ -186,9 +169,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::trigger_for_name%]"
}
},
"name": "Satellite started listening"
@@ -198,9 +178,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::trigger_for_name%]"
}
},
"name": "Satellite started processing"
@@ -210,9 +187,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::trigger_for_name%]"
}
},
"name": "Satellite started responding"

View File

@@ -13,11 +13,6 @@
- last
- any
translation_key: trigger_behavior
for:
required: true
default: 00:00:00
selector:
duration:
idle: *trigger_common
listening: *trigger_common

View File

@@ -157,6 +157,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2AuthorizeCallbackView
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
@@ -172,6 +173,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
DELETE_CURRENT_TOKEN_DELAY = 2
@bind_hass
def create_auth_code(
hass: HomeAssistant, client_id: str, credential: Credentials
) -> str:

View File

@@ -83,6 +83,7 @@ from homeassistant.helpers.trace import (
trace_path,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.dt import parse_datetime
from homeassistant.util.hass_dict import HassKey
@@ -151,7 +152,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"water_heater",
@@ -169,7 +169,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"cover",
"device_tracker",
"door",
"doorbell",
"event",
"fan",
"garage_door",
@@ -238,6 +237,7 @@ class IfAction(Protocol):
"""AND all conditions."""
@bind_hass
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
"""Return true if specified automation entity_id is on.

View File

@@ -1,12 +1,8 @@
"""Support for Amazon Web Services (AWS)."""
from __future__ import annotations
import asyncio
from collections import OrderedDict
from dataclasses import dataclass
import logging
from typing import Any
from aiobotocore.session import AioSession
import voluptuous as vol
@@ -34,22 +30,14 @@ from .const import (
CONF_REGION,
CONF_SECRET_ACCESS_KEY,
CONF_VALIDATE,
DATA_AWS,
DATA_CONFIG,
DATA_HASS_CONFIG,
DATA_SESSIONS,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
@dataclass
class AWSData:
"""Runtime data for the AWS integration."""
hass_config: ConfigType
config: dict[str, Any]
sessions: OrderedDict[str, AioSession]
AWS_CREDENTIAL_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
@@ -100,13 +88,14 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up AWS component."""
hass.data[DATA_HASS_CONFIG] = config
if (conf := config.get(DOMAIN)) is None:
# create a default conf using default profile
conf = CONFIG_SCHEMA({ATTR_CREDENTIALS: DEFAULT_CREDENTIAL})
hass.data[DATA_AWS] = AWSData(
hass_config=config, config=conf, sessions=OrderedDict()
)
hass.data[DATA_CONFIG] = conf
hass.data[DATA_SESSIONS] = OrderedDict()
hass.async_create_task(
hass.config_entries.flow.async_init(
@@ -122,8 +111,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Validate and save sessions per aws credential.
"""
data = hass.data[DATA_AWS]
conf = data.config
config = hass.data[DATA_HASS_CONFIG]
conf = hass.data[DATA_CONFIG]
if entry.source == config_entries.SOURCE_IMPORT:
if conf is None:
@@ -154,14 +143,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
validation = False
else:
data.sessions[name] = result
hass.data[DATA_SESSIONS][name] = result
# set up notify platform, no entry support for notify component yet,
# have to use discovery to load platform.
for notify_config in conf[CONF_NOTIFY]:
hass.async_create_task(
discovery.async_load_platform(
hass, Platform.NOTIFY, DOMAIN, notify_config, data.hass_config
hass, Platform.NOTIFY, DOMAIN, notify_config, config
)
)

View File

@@ -1,17 +1,10 @@
"""Constant for AWS component."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import AWSData
DOMAIN = "aws"
DATA_AWS: HassKey[AWSData] = HassKey(DOMAIN)
DATA_CONFIG = "aws_config"
DATA_HASS_CONFIG = "aws_hass_config"
DATA_SESSIONS = "aws_sessions"
CONF_ACCESS_KEY_ID = "aws_access_key_id"
CONF_CONTEXT = "context"

View File

@@ -27,7 +27,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_CONTEXT, CONF_CREDENTIAL_NAME, CONF_REGION, DATA_AWS
from .const import CONF_CONTEXT, CONF_CREDENTIAL_NAME, CONF_REGION, DATA_SESSIONS
_LOGGER = logging.getLogger(__name__)
@@ -76,12 +76,10 @@ async def async_get_service(
if CONF_CONTEXT in aws_config:
del aws_config[CONF_CONTEXT]
sessions = hass.data[DATA_AWS].sessions
if not aws_config:
# no platform config, use the first aws component credential instead
if sessions:
session = next(iter(sessions.values()))
if hass.data[DATA_SESSIONS]:
session = next(iter(hass.data[DATA_SESSIONS].values()))
else:
_LOGGER.error("Missing aws credential for %s", config[CONF_NAME])
return None
@@ -89,7 +87,7 @@ async def async_get_service(
if session is None:
credential_name = aws_config.get(CONF_CREDENTIAL_NAME)
if credential_name is not None:
session = sessions.get(credential_name)
session = hass.data[DATA_SESSIONS].get(credential_name)
if session is None:
_LOGGER.warning("No available aws session for %s", credential_name)
del aws_config[CONF_CREDENTIAL_NAME]

View File

@@ -5,7 +5,10 @@ from __future__ import annotations
import dataclasses
from typing import Any
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
from homeassistant.components.backup import (
DATA_MANAGER as BACKUP_DATA_MANAGER,
BackupManager,
)
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
@@ -28,7 +31,7 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
backup_manager = hass.data[BACKUP_DATA_MANAGER]
backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER]
backups = await async_list_backups_from_s3(
coordinator.client,
bucket=entry.data[CONF_BUCKET],

View File

@@ -21,9 +21,8 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.ssl import get_default_context
from .const import DOMAIN, MANUFACTURER, BeoModel
from .const import DOMAIN
from .services import async_setup_services
from .util import get_remotes
from .websocket import BeoWebsocket
@@ -59,6 +58,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
# Remove casts to str
assert entry.unique_id
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.unique_id)},
name=entry.title,
model=entry.data[CONF_MODEL],
)
client = MozartClient(host=entry.data[CONF_HOST], ssl_context=get_default_context())
# Check API and WebSocket connection
@@ -75,27 +83,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
await client.close_api_client()
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.unique_id)},
model=entry.data[CONF_MODEL],
)
# Create devices for paired Beoremote One remotes
for remote in await get_remotes(client):
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{remote.serial_number}_{entry.unique_id}")},
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{entry.unique_id}",
model=BeoModel.BEOREMOTE_ONE,
serial_number=remote.serial_number,
sw_version=remote.app_version,
manufacturer=MANUFACTURER,
via_device=(DOMAIN, entry.unique_id),
)
websocket = BeoWebsocket(hass, entry, client)
# Add the websocket and API client

View File

@@ -52,7 +52,6 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
_beolink_jid = ""
_client: MozartClient
_friendly_name = ""
_host = ""
_model = ""
_name = ""
@@ -112,7 +111,6 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
)
self._beolink_jid = beolink_self.jid
self._friendly_name = beolink_self.friendly_name
self._serial_number = get_serial_number_from_jid(beolink_self.jid)
await self.async_set_unique_id(self._serial_number)
@@ -151,7 +149,6 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="invalid_address")
self._model = discovery_info.hostname[:-16].replace("-", " ")
self._friendly_name = discovery_info.properties[ATTR_FRIENDLY_NAME]
self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER]
self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com"
@@ -167,13 +164,16 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
async def _create_entry(self) -> ConfigFlowResult:
"""Create the config entry for a discovered or manually configured Bang & Olufsen device."""
# Ensure that created entities have a unique and easily identifiable id and not a "friendly name"
self._name = f"{self._model}-{self._serial_number}"
return self.async_create_entry(
title=self._friendly_name,
title=self._name,
data=EntryData(
host=self._host,
jid=self._beolink_jid,
model=self._model,
name=self._friendly_name,
name=self._name,
),
)

View File

@@ -20,6 +20,7 @@ from .const import (
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DOMAIN,
MANUFACTURER,
BeoModel,
WebsocketNotification,
)
@@ -141,6 +142,12 @@ class BeoRemoteKeyEvent(BeoEvent):
self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
model=BeoModel.BEOREMOTE_ONE,
serial_number=remote.serial_number,
sw_version=remote.app_version,
manufacturer=MANUFACTURER,
via_device=(DOMAIN, self._unique_id),
)
# Make the native key name Home Assistant compatible

View File

@@ -115,7 +115,7 @@ class BeoSensorRemoteBatteryLevel(BeoSensor):
f"{remote.serial_number}_{self._unique_id}_remote_battery_level"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}
)
self._attr_native_value = remote.battery_level
self._remote = remote

View File

@@ -34,7 +34,7 @@ def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
def get_serial_number_from_jid(jid: str) -> str:
"""Get serial number from Beolink JID."""
return jid.split(".")[2].split("@", maxsplit=1)[0]
return jid.split(".")[2].split("@")[0]
async def get_remotes(client: MozartClient) -> list[PairedRemote]:

View File

@@ -29,17 +29,11 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS = {
}
CONDITIONS: dict[str, type[Condition]] = {
"is_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS, STATE_ON, support_duration=True
),
"is_not_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS, STATE_OFF, support_duration=True
),
"is_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, support_duration=True
),
"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, support_duration=True
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
),
"is_level": make_entity_numerical_condition(
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE

View File

@@ -13,11 +13,6 @@
options:
- all
- any
for: &condition_for
required: true
default: 00:00:00
selector:
duration:
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
@@ -44,7 +39,6 @@ is_charging:
device_class: battery_charging
fields:
behavior: *condition_behavior
for: *condition_for
is_not_charging:
target:
@@ -53,7 +47,6 @@ is_not_charging:
device_class: battery_charging
fields:
behavior: *condition_behavior
for: *condition_for
is_level:
target:

View File

@@ -1,10 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold type"
},
"conditions": {
@@ -13,9 +11,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is charging"
@@ -37,9 +32,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is low"
@@ -49,9 +41,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is not charging"
@@ -61,9 +50,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is not low"
@@ -101,9 +87,6 @@
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::battery::common::trigger_threshold_name%]"
}
@@ -115,9 +98,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery low"
@@ -127,9 +107,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery not low"
@@ -139,9 +116,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery started charging"
@@ -151,9 +125,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::trigger_for_name%]"
}
},
"name": "Battery stopped charging"

View File

@@ -9,11 +9,6 @@
- first
- last
- any
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
@@ -47,25 +42,21 @@
low:
fields:
behavior: *trigger_behavior
for: *trigger_for
target: *trigger_target_battery
not_low:
fields:
behavior: *trigger_behavior
for: *trigger_for
target: *trigger_target_battery
started_charging:
fields:
behavior: *trigger_behavior
for: *trigger_for
target: *trigger_target_charging
stopped_charging:
fields:
behavior: *trigger_behavior
for: *trigger_for
target: *trigger_target_charging
level_changed:
@@ -83,7 +74,6 @@ level_crossed_threshold:
target: *trigger_target_percentage
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:

View File

@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.1"],
"requirements": ["blebox-uniapi==2.5.0"],
"zeroconf": ["_bbxsrv._tcp.local."]
}

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==4.0.4",
"habluetooth==6.1.0"
"habluetooth==6.0.0"
]
}

View File

@@ -7,11 +7,9 @@ from typing import Final
from aiohttp import CookieJar
from pybravia import BraviaClient
from homeassistant.components import ssdp
from homeassistant.const import CONF_HOST, CONF_MAC, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import CONF_USE_SSL
from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator
@@ -48,19 +46,6 @@ async def async_setup_entry(
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
async def async_ssdp_callback(
discovery_info: SsdpServiceInfo, change: ssdp.SsdpChange
) -> None:
await coordinator.async_request_refresh()
config_entry.async_on_unload(
await ssdp.async_register_callback(
hass,
async_ssdp_callback,
{"nt": "urn:schemas-upnp-org:device:MediaRenderer:1", "_host": host},
)
)
return True

View File

@@ -173,9 +173,6 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
power_status = await self.client.get_power_status()
self.is_on = power_status == "active"
self.skipped_updates = 0
self.update_interval = (
timedelta(seconds=120) if power_status == "standby" else SCAN_INTERVAL
)
if not self.system_info:
self.system_info = await self.client.get_system_info()

View File

@@ -1,5 +1,4 @@
"""The Broadlink integration."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from __future__ import annotations

View File

@@ -34,8 +34,6 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink climate entities."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
device = hass.data[DOMAIN].devices[config_entry.entry_id]
if device.api.type in DOMAINS_AND_TYPES[Platform.CLIMATE]:

View File

@@ -133,8 +133,6 @@ class BroadlinkDevice[_ApiT: blk.Device = blk.Device]:
await coordinator.async_config_entry_first_refresh()
self.update_manager = update_manager
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
self.hass.data[DOMAIN].devices[config.entry_id] = self
self.reset_jobs.append(config.add_update_listener(self.async_update))

View File

@@ -32,8 +32,6 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink light."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
device = hass.data[DOMAIN].devices[config_entry.entry_id]
lights = []

View File

@@ -95,8 +95,6 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Broadlink remote."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
device = hass.data[DOMAIN].devices[config_entry.entry_id]
remote = BroadlinkRemote(
device,

View File

@@ -31,8 +31,6 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink select."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
device = hass.data[DOMAIN].devices[config_entry.entry_id]
async_add_entities([BroadlinkDayOfWeek(device)])

View File

@@ -108,8 +108,6 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink sensor."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
device = hass.data[DOMAIN].devices[config_entry.entry_id]
sensor_data = device.update_manager.coordinator.data
sensors = [

View File

@@ -1,5 +1,4 @@
"""Support for Broadlink switches."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from __future__ import annotations

View File

@@ -22,8 +22,6 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink time."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
device = hass.data[DOMAIN].devices[config_entry.entry_id]
async_add_entities([BroadlinkTime(device)])

View File

@@ -738,7 +738,10 @@ class CalendarEntity(Entity):
listener(None)
return
event_list: list[JsonValueType] = [event.as_dict() for event in events]
event_list: list[JsonValueType] = [
dataclasses.asdict(event, dict_factory=_list_events_dict_factory)
for event in events
]
listener(event_list)
async def async_get_events(

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