mirror of
https://github.com/home-assistant/core.git
synced 2026-05-15 23:01:45 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 918185fda4 |
@@ -27,13 +27,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.
|
||||
|
||||
+1
-3
@@ -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.
|
||||
---
|
||||
|
||||
@@ -14,8 +14,6 @@ description: Everything you need to know to build, test and review Home Assistan
|
||||
- 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
|
||||
@@ -23,4 +23,3 @@ requirements_all.txt linguist-generated=true
|
||||
requirements_test_all.txt linguist-generated=true
|
||||
requirements_test_pre_commit.txt linguist-generated=true
|
||||
script/hassfest/docker/Dockerfile linguist-generated=true
|
||||
.github/workflows/*.lock.yml linguist-generated=true merge=ours
|
||||
|
||||
@@ -38,4 +38,4 @@ When validation guarantees a dict key exists, prefer direct key access (`data["k
|
||||
|
||||
# Skills
|
||||
|
||||
- ha-integration-knowledge: .claude/skills/ha-integration-knowledge/SKILL.md
|
||||
- Home Assistant Integration knowledge: .claude/skills/integrations/SKILL.md
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,361 +0,0 @@
|
||||
---
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "requirements*.txt"
|
||||
- "homeassistant/package_constraints.txt"
|
||||
- "pyproject.toml"
|
||||
forks: ["*"]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
network:
|
||||
allowed:
|
||||
- python
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [default]
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
max: 1
|
||||
description: >
|
||||
Checks changed Python package requirements on PRs targeting the core repo
|
||||
(including fork PRs): verifies licenses match PyPI metadata, source
|
||||
repositories are publicly accessible, PyPI releases were uploaded via
|
||||
automated CI (Trusted Publisher attestation), the package's release pipeline
|
||||
uses OIDC or equivalent automated credentials (not static tokens), and the PR
|
||||
description contains the required links.
|
||||
---
|
||||
|
||||
# Requirements License and Availability Check
|
||||
|
||||
You are a code review assistant for the Home Assistant project. Your job is to
|
||||
review changes to Python package requirements and verify they meet the project's
|
||||
standards.
|
||||
|
||||
## Context
|
||||
|
||||
- Home Assistant uses `requirements_all.txt` (all integration packages),
|
||||
`requirements.txt` (core packages), `requirements_test.txt` (test
|
||||
dependencies), and `requirements_test_all.txt` (all test dependencies) to
|
||||
declare Python dependencies.
|
||||
- Each integration lists its packages in `homeassistant/components/<name>/manifest.json`
|
||||
under the `requirements` field.
|
||||
- Allowed licenses are maintained in `script/licenses.py` under
|
||||
`OSI_APPROVED_LICENSES_SPDX` (SPDX identifiers) and `OSI_APPROVED_LICENSES`
|
||||
(classifier strings).
|
||||
|
||||
## Step 1 — Identify Changed Packages
|
||||
|
||||
Use the GitHub tool to fetch the PR diff. Look for lines that were added (`+`)
|
||||
or removed (`-`) in **all** of these files:
|
||||
- `requirements.txt`
|
||||
- `requirements_all.txt`
|
||||
- `requirements_test.txt`
|
||||
- `requirements_test_all.txt`
|
||||
- `homeassistant/package_constraints.txt`
|
||||
- `pyproject.toml`
|
||||
|
||||
For each changed line that contains a package pin (e.g. `SomePackage==1.2.3`),
|
||||
classify it as:
|
||||
- **New package**: the package name appears only in `+` lines, with no
|
||||
corresponding `-` line for the same package name.
|
||||
- **Version bump**: the same package name appears in both `+` lines (new
|
||||
version) and `-` lines (old version), with different version numbers.
|
||||
|
||||
Record the **old version** and **new version** for every version bump — you
|
||||
will need these values in Step 4.
|
||||
|
||||
Ignore comment lines (starting with `#`), lines that start with `-r ` (file
|
||||
includes), and lines that don't contain `==`.
|
||||
|
||||
## Step 2 — Check License via PyPI
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
1. Fetch `https://pypi.org/pypi/{package_name}/json` (use the exact
|
||||
package name as it appears on PyPI).
|
||||
2. From the JSON response, extract:
|
||||
- `info.license` — free-text license field
|
||||
- `info.license_expression` — SPDX expression (if present)
|
||||
- `info.classifiers` — filter for entries starting with `"License ::"`.
|
||||
3. Determine if the license is in the approved list from `script/licenses.py`:
|
||||
- SPDX identifiers: compare against `OSI_APPROVED_LICENSES_SPDX`
|
||||
- Classifier strings: compare against `OSI_APPROVED_LICENSES`
|
||||
4. Flag a package as ❌ if the license is unknown, missing, or not in the
|
||||
approved list. Flag as ⚠️ if the license information is ambiguous or cannot
|
||||
be definitively determined.
|
||||
|
||||
## Step 2b — Verify PyPI Release Was Uploaded by CI
|
||||
|
||||
For each new or bumped package, verify that the release on PyPI was published
|
||||
automatically by a CI pipeline (via OIDC Trusted Publisher), not uploaded
|
||||
manually.
|
||||
|
||||
1. Fetch the PyPI JSON for the specific version being introduced or bumped:
|
||||
`https://pypi.org/pypi/{package_name}/{version}/json`
|
||||
2. Inspect the `urls` array in the response. For each distribution file (wheel
|
||||
or sdist), note the filename.
|
||||
3. For each filename, attempt to fetch the PyPI provenance attestation:
|
||||
`https://pypi.org/integrity/{package_name}/{version}/{filename}/provenance`
|
||||
- If the response is HTTP 200 and contains a valid attestation object,
|
||||
inspect `attestation_bundles[*].publisher`. A Trusted Publisher attestation
|
||||
will have a `kind` identifying the CI system (e.g. `"GitHub Actions"`,
|
||||
`"GitLab"`) and a `repository` or `project` field matching the source
|
||||
repository.
|
||||
- If at least one distribution file has a valid Trusted Publisher attestation,
|
||||
mark ✅ CI-uploaded.
|
||||
- If no attestation is found for any file (404 for all), mark ❌ — "Release
|
||||
has no provenance attestation; it may have been uploaded manually".
|
||||
- If an attestation exists but the `publisher` does not identify a recognized
|
||||
CI system or Trusted Publisher, mark ⚠️ — "Attestation present but
|
||||
publisher cannot be verified as automated CI".
|
||||
|
||||
Note: if PyPI returns an error fetching the per-version JSON, fall back to the
|
||||
latest JSON (`https://pypi.org/pypi/{package_name}/json`) and look up the
|
||||
specific version in the `releases` dict.
|
||||
|
||||
## Step 3 — Check Repository Availability
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
1. From the PyPI JSON at `info.project_urls`, find the source repository URL
|
||||
(keys such as `"Source"`, `"Homepage"`, `"Repository"`, or `"Source Code"`).
|
||||
2. Use web-fetch to perform a GET request to the repository URL.
|
||||
3. If the response returns HTTP 200 and the page is publicly accessible, mark ✅.
|
||||
4. If the URL is missing, returns a non-200 status, or redirects to a login
|
||||
page, mark ❌ with a note that the repository could not be verified as public.
|
||||
|
||||
## Step 4 — Check PR Description
|
||||
|
||||
Read the PR body from the GitHub API using the PR number `${{ github.event.pull_request.number }}`.
|
||||
Extract all URLs present in the PR body.
|
||||
|
||||
### 4a — New packages: repository link required
|
||||
|
||||
For **new packages** (brand-new dependency not previously in any requirements
|
||||
file): the PR description must contain a link that points to the package's
|
||||
**source repository** as identified in Step 3 (the URL recorded from
|
||||
`info.project_urls`). A PyPI page link alone is **not** acceptable — the link
|
||||
must point directly to the source repository (e.g. a GitHub or GitLab URL).
|
||||
|
||||
- If a URL in the PR body matches (or is a sub-path of) the source repository
|
||||
URL identified via PyPI, mark ✅.
|
||||
- If the PR body contains a source repository URL that does **not** match the
|
||||
repository URL found in the package's PyPI metadata (`info.project_urls`),
|
||||
mark ❌ — "PR description links to `<pr_url>` but PyPI reports the source
|
||||
repository as `<pypi_repo_url>`; please use the correct repository URL."
|
||||
- If no source repository URL is present in the PR body at all, mark ❌ —
|
||||
"PR description must link to the source repository at `<repo_url>` (found
|
||||
via PyPI). A PyPI page link is not sufficient."
|
||||
|
||||
### 4b — Version bumps: changelog or diff link required
|
||||
|
||||
For **version bumps**: the PR description must contain a link to a changelog,
|
||||
release notes page, or a diff/comparison URL that references the **correct
|
||||
versions** being bumped (old → new).
|
||||
|
||||
Checks to perform for each bumped package (old version = X, new version = Y):
|
||||
1. Extract all URLs from the PR body that contain the repository's domain or
|
||||
path (as identified in Step 3).
|
||||
2. Verify that at least one such URL includes both the old version string and
|
||||
new version string in some form — e.g. a GitHub compare URL like
|
||||
`compare/vX...vY`, a releases URL mentioning version Y, or a
|
||||
`CHANGELOG.md` anchor referencing Y.
|
||||
3. If no URL matches, check if the PR body contains any changelog/diff link at
|
||||
all for this package.
|
||||
|
||||
Outcome:
|
||||
- ✅ — a URL pointing to the correct repo with version references covering the
|
||||
exact bump (X → Y).
|
||||
- ⚠️ — a changelog/diff link exists but does not clearly reference the correct
|
||||
versions or the correct repository; explain what was found and what is
|
||||
expected.
|
||||
- ❌ — no changelog or diff link found at all in the PR description for this
|
||||
package.
|
||||
|
||||
### 4c — Diff consistency check
|
||||
|
||||
For each **version bump**, verify that the version change recorded in the diff
|
||||
(Step 1) is internally consistent:
|
||||
- The `-` line must contain the old version and the `+` line must contain the
|
||||
new version for the same package name.
|
||||
- Flag ❌ if the diff shows a downgrade (new version < old version) without an
|
||||
explanation, or if the version strings cannot be parsed.
|
||||
|
||||
## Step 5 — Verify Source Repository is Publicly Accessible
|
||||
|
||||
Before inspecting the release pipeline, confirm that the source repository
|
||||
identified in Step 3 is publicly reachable.
|
||||
|
||||
For each new or bumped package:
|
||||
|
||||
1. Use the source repository URL recorded in Step 3.
|
||||
2. If no repository URL was found in `info.project_urls`, mark ❌ — "No source
|
||||
repository URL found in PyPI metadata; a public source repository is
|
||||
required."
|
||||
3. If a repository URL was found, perform a GET request to that URL (using
|
||||
web-fetch). If the response is HTTP 200 and returns a publicly accessible
|
||||
page (not a login redirect or error page), mark ✅.
|
||||
4. If the response is non-200, the URL redirects to a login/authentication page,
|
||||
or the repository appears private or unavailable, mark ❌ — "Source
|
||||
repository at `<repo_url>` is not publicly accessible. Home Assistant
|
||||
requires all dependencies to have publicly available source code." **Do not
|
||||
proceed with the release pipeline check (Step 6) for this package.**
|
||||
|
||||
## Step 6 — Check Release Pipeline Sanity
|
||||
|
||||
For each new or bumped package, determine the source repository host from the
|
||||
URL identified in Step 3, then inspect whether the project's release/publish CI
|
||||
workflow is sane. The checks differ by hosting provider.
|
||||
|
||||
### GitHub repositories (`github.com`)
|
||||
|
||||
1. Using the GitHub API, list the workflows in the source repository:
|
||||
`GET /repos/{owner}/{repo}/actions/workflows`
|
||||
2. Identify any workflow whose name or filename suggests publishing to PyPI
|
||||
(e.g., contains "release", "publish", "pypi", or "deploy").
|
||||
3. Fetch the workflow file content and check the following:
|
||||
a. **Trigger sanity**: The publish job should be triggered by `push` to tags,
|
||||
`release: published`, or `workflow_run` on a release job — **not** solely
|
||||
by `workflow_dispatch` with no additional guards. A `workflow_dispatch`
|
||||
trigger alongside other triggers is acceptable. Mark ❌ if the only trigger
|
||||
is manual `workflow_dispatch` with no environment protection rules.
|
||||
b. **OIDC / Trusted Publisher**: The workflow should use OIDC-based publishing.
|
||||
Look for `id-token: write` permission and one of:
|
||||
- `pypa/gh-action-pypi-publish` action
|
||||
- `actions/attest-build-provenance` action
|
||||
- Any step that sets `TWINE_PASSWORD` from `secrets.PYPI_TOKEN` directly
|
||||
(flag ❌ if a long-lived API token is used instead of OIDC).
|
||||
Mark ✅ if OIDC is used, ⚠️ if the publish method cannot be determined,
|
||||
❌ if a static secret token is the only credential.
|
||||
c. **No manual upload bypass**: Verify there is no step that calls
|
||||
`twine upload` or `pip upload` outside of a properly gated job (e.g., one
|
||||
that requires an environment approval). Flag ⚠️ if such steps exist.
|
||||
4. If no publish workflow is found in the repository, mark ⚠️ — "No publish
|
||||
workflow found; it is unclear how this package is released to PyPI."
|
||||
|
||||
### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
1. Use the GitLab REST API to list CI/CD pipeline configuration files. First
|
||||
resolve the project ID via
|
||||
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`
|
||||
and note the `id` field.
|
||||
2. Fetch the repository's `.gitlab-ci.yml` (and any included files) using
|
||||
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`
|
||||
(use web-fetch for public repos).
|
||||
3. Identify any job whose name or `stage` suggests publishing to PyPI
|
||||
(e.g., "publish", "deploy", "release", "pypi").
|
||||
4. For each such job, check:
|
||||
a. **Trigger sanity**: The job should run only on tag pipelines (`only: tags`
|
||||
or `rules: - if: $CI_COMMIT_TAG`) or on protected branches — **not**
|
||||
solely on manual triggers (`when: manual`) with no additional protection.
|
||||
Mark ❌ if the only trigger is manual with no environment or protected-branch
|
||||
guard.
|
||||
b. **Automated credentials**: The job should use GitLab's OIDC ID token
|
||||
(`id_tokens:` block) and `pypa/gh-action-pypi-publish` equivalent, or
|
||||
reference `secrets.PYPI_TOKEN` / `$PYPI_TOKEN` injected from GitLab CI/CD
|
||||
protected variables (flag ❌ if the token is hard-coded or unprotected).
|
||||
Mark ✅ if OIDC or protected CI variables are used, ⚠️ if the method
|
||||
cannot be determined, ❌ if credentials appear to be insecure.
|
||||
c. **No manual upload bypass**: Flag ⚠️ if any job calls `twine upload`
|
||||
without being behind a protected-variable or environment guard.
|
||||
5. If no publish job is found, mark ⚠️ — "No publish job found in .gitlab-ci.yml;
|
||||
it is unclear how this package is released to PyPI."
|
||||
|
||||
### Other code hosting providers
|
||||
|
||||
For repositories hosted on platforms other than GitHub or GitLab (e.g.,
|
||||
Bitbucket, Codeberg, Gitea, Sourcehut):
|
||||
1. Use web-fetch to retrieve the repository's root page and look for any
|
||||
publicly visible CI configuration files (e.g., `.circleci/config.yml`,
|
||||
`Jenkinsfile`, `azure-pipelines.yml`, `bitbucket-pipelines.yml`,
|
||||
`.builds/*.yml` for Sourcehut).
|
||||
2. Apply the same conceptual checks as above:
|
||||
- Does publishing run on automated triggers (tags/releases), not solely
|
||||
manual ones?
|
||||
- Are credentials injected by the CI system (not hard-coded)?
|
||||
- Is there a `twine upload` or equivalent step that could be run manually?
|
||||
3. If no CI configuration can be retrieved, mark ⚠️ — "Release pipeline could
|
||||
not be inspected; hosting provider is not GitHub or GitLab."
|
||||
|
||||
## Step 7 — Post a Review Comment
|
||||
|
||||
**Always** post a review comment using `add-comment`, regardless of whether
|
||||
packages pass or fail.
|
||||
|
||||
In the table, use **only the status icon** (✅, ❌, ⚠️, or — for not
|
||||
applicable) in each check column — do not include any additional text inside
|
||||
table cells. Place all detailed findings in a collapsible `<details>` section
|
||||
immediately after the table.
|
||||
|
||||
Use the following structure:
|
||||
|
||||
```
|
||||
## Requirements Check
|
||||
|
||||
| Package | Type | Old→New | License | Repository Public | CI Upload | Release Pipeline | PR Link | Diff Consistent |
|
||||
|---------|------|---------|---------|-------------------|-----------|------------------|---------|-----------------|
|
||||
| PackageA | bump | 1.2.3→1.3.0 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| PackageB | new | —→4.5.6 | ❌ | ✅ | ❌ | ⚠️ | ❌ | ✅ |
|
||||
| PackageC | bump | 2.0.0→2.1.0 | ✅ | ❌ | — | — | ⚠️ | ✅ |
|
||||
|
||||
<details [open if any check has ❌ or ⚠️]>
|
||||
<summary>Check details</summary>
|
||||
|
||||
**PackageA (bump 1.2.3→1.3.0):**
|
||||
- License: ✅ MIT
|
||||
- Repository Public: ✅
|
||||
- CI Upload: ✅
|
||||
- Release Pipeline: ✅ OIDC (`pypa/gh-action-pypi-publish`, `release: published` trigger)
|
||||
- PR Link: ✅ compare/v1.2.3...v1.3.0
|
||||
- Diff Consistent: ✅
|
||||
|
||||
**PackageB (new —→4.5.6):**
|
||||
- License: ❌ UNKNOWN — license could not be determined
|
||||
- Repository Public: ✅
|
||||
- CI Upload: ❌ — release has no provenance attestation; it may have been uploaded manually
|
||||
- Release Pipeline: ⚠️ — no publish workflow found
|
||||
- PR Link: ❌ — missing repo link; PR description must link to the source repository
|
||||
- Diff Consistent: ✅
|
||||
|
||||
**PackageC (bump 2.0.0→2.1.0):**
|
||||
- License: ✅ Apache-2.0
|
||||
- Repository Public: ❌ — source repository is not publicly accessible
|
||||
- CI Upload: — (skipped; repository not accessible)
|
||||
- Release Pipeline: — (skipped; repository not accessible)
|
||||
- PR Link: ⚠️ — link found but points to wrong repository
|
||||
- Diff Consistent: ✅
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
The `<details>` element behavior:
|
||||
- If **all packages pass every check** (all ✅ or —): use `<details>` (collapsed
|
||||
by default) and add a brief confirmation before the details section:
|
||||
`All requirements checks passed. ✅`
|
||||
- If **any package has a failure or warning** (any ❌ or ⚠️): use `<details open>`
|
||||
(expanded by default). The details section must explain each failure and what
|
||||
the contributor needs to fix, including:
|
||||
- The expected source repository URL (from PyPI) when a link is missing or wrong.
|
||||
- The expected version range (old → new) when a changelog URL doesn't match the diff.
|
||||
- Whether the PyPI release lacks provenance attestation or uses an insecure publish method.
|
||||
- Whether the source repository is not publicly accessible.
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive and helpful. Provide direct links where possible so the
|
||||
contributor can quickly fix the issue.
|
||||
- If PyPI returns an error for a package, mention that it could not be found and
|
||||
suggest the contributor verify the package name.
|
||||
- For packages that only appear in `homeassistant/package_constraints.txt` or
|
||||
`pyproject.toml` without being tied to a specific integration, the PR
|
||||
description link requirement still applies.
|
||||
- When checking test-only packages (from `requirements_test.txt` or
|
||||
`requirements_test_all.txt`), apply the same license, repository, and PR
|
||||
description checks as for production dependencies.
|
||||
- A package that appears in both a production file and a test file should only
|
||||
be reported once; use the production file entry as the canonical one.
|
||||
@@ -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"
|
||||
|
||||
@@ -18,12 +18,11 @@ 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.24.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
- --pedantic
|
||||
exclude: ^\.github/workflows/check-requirements\.lock\.yml$
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
@@ -47,7 +46,6 @@ repos:
|
||||
additional_dependencies:
|
||||
- prettier@3.6.2
|
||||
- prettier-plugin-sort-json@4.2.0
|
||||
exclude: ^\.github/workflows/check-requirements\.lock\.yml$
|
||||
- repo: https://github.com/cdce8p/python-typing-update
|
||||
rev: v0.6.0
|
||||
hooks:
|
||||
|
||||
@@ -599,7 +599,6 @@ homeassistant.components.vallox.*
|
||||
homeassistant.components.valve.*
|
||||
homeassistant.components.velbus.*
|
||||
homeassistant.components.velux.*
|
||||
homeassistant.components.victron_gx.*
|
||||
homeassistant.components.vivotek.*
|
||||
homeassistant.components.vlc_telnet.*
|
||||
homeassistant.components.vodafone_station.*
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
ignore: |
|
||||
tests/fixtures/core/config/yaml_errors/
|
||||
.github/workflows/check-requirements.lock.yml
|
||||
rules:
|
||||
braces:
|
||||
level: error
|
||||
|
||||
Generated
-4
@@ -400,8 +400,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
|
||||
@@ -1255,8 +1253,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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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))]
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
@@ -25,9 +24,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 +33,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 +54,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 +63,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 +168,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 +177,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"
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -20,9 +19,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 +28,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 +37,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 +46,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 +55,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 +64,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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -43,6 +43,7 @@ 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,
|
||||
@@ -65,6 +66,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
MIN_THINKING_BUDGET,
|
||||
TOOL_SEARCH_UNSUPPORTED_MODELS,
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS,
|
||||
PromptCaching,
|
||||
)
|
||||
from .coordinator import model_alias
|
||||
@@ -387,6 +389,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
else cv.positive_int,
|
||||
}
|
||||
|
||||
model = self.options[CONF_CHAT_MODEL]
|
||||
|
||||
if (
|
||||
self.model_info.capabilities
|
||||
and self.model_info.capabilities.thinking.supported
|
||||
@@ -441,34 +445,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(
|
||||
|
||||
@@ -50,6 +50,15 @@ DEFAULT = {
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
}
|
||||
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
CODE_EXECUTION_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
TOOL_SEARCH_UNSUPPORTED_MODELS = [
|
||||
"claude-3",
|
||||
"claude-haiku",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -124,14 +124,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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,6 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_idle: *condition_common
|
||||
is_listening: *condition_common
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -11,9 +10,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 +19,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 +28,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 +37,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"
|
||||
|
||||
@@ -169,7 +169,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"door",
|
||||
"doorbell",
|
||||
"event",
|
||||
"fan",
|
||||
"garage_door",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
@@ -13,9 +12,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 +33,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 +42,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 +51,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"
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""The Broadlink integration."""
|
||||
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -6,6 +6,7 @@ DOMAIN = "broadlink"
|
||||
|
||||
DOMAINS_AND_TYPES = {
|
||||
Platform.CLIMATE: {"HYS"},
|
||||
Platform.INFRARED: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
|
||||
Platform.LIGHT: {"LB1", "LB2"},
|
||||
Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
|
||||
Platform.SELECT: {"HYS"},
|
||||
@@ -44,3 +45,6 @@ DEVICE_TYPES = set.union(*DOMAINS_AND_TYPES.values())
|
||||
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_TIMEOUT = 5
|
||||
|
||||
# Broadlink IR packet format - repeat count byte offset
|
||||
IR_PACKET_REPEAT_INDEX = 1
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Infrared platform for Broadlink remotes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from broadlink.exceptions import BroadlinkException
|
||||
from broadlink.remote import pulses_to_data as _bl_pulses_to_data
|
||||
import infrared_protocols
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, IR_PACKET_REPEAT_INDEX
|
||||
from .entity import BroadlinkEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .device import BroadlinkDevice
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class BroadlinkIRCommand(InfraredCommand):
|
||||
"""Raw IR command with optional Broadlink hardware repeat count.
|
||||
|
||||
This class lets you send raw timing data through a Broadlink infrared
|
||||
entity. The repeat_count maps directly to the Broadlink packet repeat
|
||||
byte: the device will re-transmit the entire IR burst that many
|
||||
additional times after the first transmission.
|
||||
|
||||
Use this when you have existing Broadlink-encoded IR data (e.g. from
|
||||
IR code databases like SmartIR) and want to use it with the new
|
||||
infrared platform.
|
||||
|
||||
Protocol-aware commands (infrared_protocols.NECCommand, LgTVCommand,
|
||||
etc.) manage repeats *inside* get_raw_timings() and should use the
|
||||
default repeat=0. Only BroadlinkIRCommand should set hardware repeat.
|
||||
|
||||
Example: Migrating IR code database base64 codes to the infrared platform:
|
||||
|
||||
import base64
|
||||
from broadlink.remote import data_to_pulses
|
||||
from homeassistant.components.broadlink.infrared import BroadlinkIRCommand
|
||||
from homeassistant.components.broadlink.const import IR_PACKET_REPEAT_INDEX
|
||||
|
||||
# Decode base64 IR code (e.g. from IR code database)
|
||||
packet_data = base64.b64decode(b64_code)
|
||||
repeat_count = packet_data[IR_PACKET_REPEAT_INDEX]
|
||||
|
||||
# Parse Broadlink packet to microsecond timings
|
||||
pulses = data_to_pulses(packet_data)
|
||||
timings = list(zip(pulses[::2], pulses[1::2]))
|
||||
if len(pulses) % 2:
|
||||
timings.append((pulses[-1], 0))
|
||||
|
||||
# Create command
|
||||
cmd = BroadlinkIRCommand(timings, repeat_count=repeat_count)
|
||||
await infrared.async_send_command(hass, entity_id, cmd)
|
||||
"""
|
||||
|
||||
# Standard IR carrier frequency. Broadlink hardware handles the carrier
|
||||
# internally, so this value is informational only.
|
||||
MODULATION = 38000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timings: list[tuple[int, int]],
|
||||
repeat_count: int = 0,
|
||||
) -> None:
|
||||
"""Initialize with timing pairs and optional repeat count.
|
||||
|
||||
Args:
|
||||
timings: List of (mark_us, space_us) pairs in microseconds.
|
||||
repeat_count: Broadlink hardware repeat count (0 = send once).
|
||||
Must be 0–255 (the hardware repeat byte is a single unsigned byte).
|
||||
|
||||
Raises:
|
||||
ValueError: If repeat_count is outside 0–255 range.
|
||||
"""
|
||||
if not 0 <= repeat_count <= 255:
|
||||
raise ValueError(f"repeat_count must be 0–255, got {repeat_count}")
|
||||
super().__init__(modulation=self.MODULATION, repeat_count=repeat_count)
|
||||
self._timings = [
|
||||
infrared_protocols.Timing(high_us=high, low_us=low) for high, low in timings
|
||||
]
|
||||
|
||||
def get_raw_timings(self) -> list[infrared_protocols.Timing]:
|
||||
"""Return timing pairs for transmission."""
|
||||
return self._timings
|
||||
|
||||
|
||||
def timings_to_broadlink_packet(
|
||||
timings: list[tuple[int, int]],
|
||||
repeat: int = 0,
|
||||
) -> bytes:
|
||||
"""Convert raw timing pairs (high_us, low_us) to a Broadlink IR packet.
|
||||
|
||||
Args:
|
||||
timings: List of (mark_us, space_us) pairs in microseconds.
|
||||
repeat: Number of extra repeats (0 = send once).
|
||||
|
||||
Returns:
|
||||
Binary packet ready for Broadlink send_data().
|
||||
|
||||
"""
|
||||
if not 0 <= repeat <= 255:
|
||||
raise ValueError(f"repeat must be 0–255, got {repeat}")
|
||||
|
||||
# Flatten (mark, space) pairs into a pulse list, omitting any zero-length spaces
|
||||
pulses: list[int] = []
|
||||
for high_us, low_us in timings:
|
||||
pulses.append(high_us)
|
||||
if low_us:
|
||||
pulses.append(low_us)
|
||||
|
||||
# Use broadlink library's encoder (tick=32.84 µs)
|
||||
packet = bytearray(_bl_pulses_to_data(pulses))
|
||||
packet[IR_PACKET_REPEAT_INDEX] = repeat
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Broadlink infrared entity."""
|
||||
device = hass.data[DOMAIN].devices[config_entry.entry_id]
|
||||
async_add_entities([BroadlinkInfraredEntity(device)])
|
||||
|
||||
|
||||
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEntity):
|
||||
"""Broadlink infrared transmitter entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "infrared"
|
||||
|
||||
def __init__(self, device: BroadlinkDevice) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(device)
|
||||
self._attr_unique_id = f"{device.unique_id}-infrared"
|
||||
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command via the Broadlink device.
|
||||
|
||||
Handles two types of repeat behavior:
|
||||
|
||||
1. Protocol-aware commands (NECCommand, etc.): These encode repeats
|
||||
(like NEC repeat codes) inside their get_raw_timings() data. The
|
||||
Broadlink packet is sent with repeat=0.
|
||||
|
||||
2. BroadlinkIRCommand: Carries Broadlink hardware repeat count,
|
||||
which tells the device to re-transmit the entire burst N times.
|
||||
This is used for protocols/commands that need multiple full frame
|
||||
transmissions (e.g. legacy SmartIR data).
|
||||
|
||||
Using isinstance check ensures protocol-level repeats (already in
|
||||
timing data) don't get conflated with hardware repeats.
|
||||
"""
|
||||
timings = [
|
||||
(timing.high_us, timing.low_us) for timing in command.get_raw_timings()
|
||||
]
|
||||
|
||||
# Only BroadlinkIRCommand uses Broadlink hardware repeat. Protocol-aware
|
||||
# commands (NECCommand, etc.) encode repeats inside get_raw_timings()
|
||||
# and must use hardware repeat=0 to avoid double-repeating.
|
||||
if isinstance(command, BroadlinkIRCommand):
|
||||
repeat = command.repeat_count
|
||||
else:
|
||||
repeat = 0
|
||||
|
||||
packet = timings_to_broadlink_packet(timings, repeat=repeat)
|
||||
|
||||
try:
|
||||
await self._device.async_request(self._device.api.send_data, packet)
|
||||
except (BroadlinkException, OSError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_command_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Broadlink",
|
||||
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["infrared"],
|
||||
"dhcp": [
|
||||
{
|
||||
"registered_devices": true
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -49,6 +49,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"infrared": {
|
||||
"infrared": {
|
||||
"name": "IR transmitter"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"day_of_week": {
|
||||
"name": "Day of week",
|
||||
@@ -77,5 +82,10 @@
|
||||
"name": "Total consumption"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"send_command_failed": {
|
||||
"message": "Failed to send IR command: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Support for Broadlink switches."""
|
||||
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.2.0"],
|
||||
"requirements": ["python-bsblan==5.1.4"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -7,9 +7,7 @@ from homeassistant.helpers.condition import Condition, make_entity_state_conditi
|
||||
from .const import DOMAIN
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_event_active": make_entity_state_condition(
|
||||
DOMAIN, STATE_ON, support_duration=True
|
||||
),
|
||||
"is_event_active": make_entity_state_condition(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,8 +12,3 @@ is_event_active:
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least"
|
||||
"condition_behavior_name": "Condition passes if"
|
||||
},
|
||||
"conditions": {
|
||||
"is_event_active": {
|
||||
@@ -9,9 +8,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::calendar::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::calendar::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Calendar event is active"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Component to embed Google Cast."""
|
||||
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -65,8 +65,6 @@ class ChromecastInfo:
|
||||
"""
|
||||
cast_info = self.cast_info
|
||||
if self.cast_info.cast_type is None or self.cast_info.manufacturer is None:
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=hass-use-runtime-data
|
||||
unknown_models = hass.data[DOMAIN]["unknown_models"]
|
||||
if self.cast_info.model_name not in unknown_models:
|
||||
# Manufacturer and cast type is not available in mDNS data,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Provide functionality to interact with Cast devices on the network."""
|
||||
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_hvac_mode": ClimateHVACModeCondition,
|
||||
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF, support_duration=True),
|
||||
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
|
||||
"is_on": make_entity_state_condition(
|
||||
DOMAIN,
|
||||
{
|
||||
|
||||
@@ -39,16 +39,7 @@
|
||||
- domain: number
|
||||
device_class: temperature
|
||||
|
||||
is_off:
|
||||
target: *condition_climate_target
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_off: *condition_common
|
||||
is_on: *condition_common
|
||||
is_cooling: *condition_common
|
||||
is_drying: *condition_common
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
@@ -9,34 +8,34 @@
|
||||
},
|
||||
"conditions": {
|
||||
"is_cooling": {
|
||||
"description": "Tests if one or more thermostats are cooling.",
|
||||
"description": "Tests if one or more climate-control devices are cooling.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat is cooling"
|
||||
"name": "Climate-control device is cooling"
|
||||
},
|
||||
"is_drying": {
|
||||
"description": "Tests if one or more thermostats are drying.",
|
||||
"description": "Tests if one or more climate-control devices are drying.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat is drying"
|
||||
"name": "Climate-control device is drying"
|
||||
},
|
||||
"is_heating": {
|
||||
"description": "Tests if one or more thermostats are heating.",
|
||||
"description": "Tests if one or more climate-control devices are heating.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat is heating"
|
||||
"name": "Climate-control device is heating"
|
||||
},
|
||||
"is_hvac_mode": {
|
||||
"description": "Tests if one or more thermostats are set to a specific HVAC mode.",
|
||||
"description": "Tests if one or more climate-control devices are set to a specific HVAC mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
@@ -46,31 +45,28 @@
|
||||
"name": "Modes"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat HVAC mode"
|
||||
"name": "Climate-control device HVAC mode"
|
||||
},
|
||||
"is_off": {
|
||||
"description": "Tests if one or more thermostats are off.",
|
||||
"description": "Tests if one or more climate-control devices are off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::climate::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat is off"
|
||||
"name": "Climate-control device is off"
|
||||
},
|
||||
"is_on": {
|
||||
"description": "Tests if one or more thermostats are on.",
|
||||
"description": "Tests if one or more climate-control devices are on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat is on"
|
||||
"name": "Climate-control device is on"
|
||||
},
|
||||
"target_humidity": {
|
||||
"description": "Tests the humidity setpoint of one or more thermostats.",
|
||||
"description": "Tests the humidity setpoint of one or more climate-control devices.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
@@ -79,10 +75,10 @@
|
||||
"name": "[%key:component::climate::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat target humidity"
|
||||
"name": "Climate-control device target humidity"
|
||||
},
|
||||
"target_temperature": {
|
||||
"description": "Tests the temperature setpoint of one or more thermostats.",
|
||||
"description": "Tests the temperature setpoint of one or more climate-control devices.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
@@ -91,7 +87,7 @@
|
||||
"name": "[%key:component::climate::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat target temperature"
|
||||
"name": "Climate-control device target temperature"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
@@ -288,67 +284,67 @@
|
||||
},
|
||||
"services": {
|
||||
"set_fan_mode": {
|
||||
"description": "Sets the fan mode of a thermostat.",
|
||||
"description": "Sets the fan mode of a climate-control device.",
|
||||
"fields": {
|
||||
"fan_mode": {
|
||||
"description": "Fan operation mode.",
|
||||
"name": "Fan mode"
|
||||
}
|
||||
},
|
||||
"name": "Set thermostat fan mode"
|
||||
"name": "Set climate-control device fan mode"
|
||||
},
|
||||
"set_humidity": {
|
||||
"description": "Sets the target humidity of a thermostat.",
|
||||
"description": "Sets the target humidity of a climate-control device.",
|
||||
"fields": {
|
||||
"humidity": {
|
||||
"description": "Target humidity.",
|
||||
"name": "Humidity"
|
||||
}
|
||||
},
|
||||
"name": "Set thermostat target humidity"
|
||||
"name": "Set climate-control device target humidity"
|
||||
},
|
||||
"set_hvac_mode": {
|
||||
"description": "Sets the HVAC mode of a thermostat.",
|
||||
"description": "Sets the HVAC mode of a climate-control device.",
|
||||
"fields": {
|
||||
"hvac_mode": {
|
||||
"description": "HVAC operation mode.",
|
||||
"name": "HVAC mode"
|
||||
}
|
||||
},
|
||||
"name": "Set thermostat HVAC mode"
|
||||
"name": "Set climate-control device HVAC mode"
|
||||
},
|
||||
"set_preset_mode": {
|
||||
"description": "Sets the preset mode of a thermostat.",
|
||||
"description": "Sets the preset mode of a climate-control device.",
|
||||
"fields": {
|
||||
"preset_mode": {
|
||||
"description": "Preset mode.",
|
||||
"name": "Preset mode"
|
||||
}
|
||||
},
|
||||
"name": "Set thermostat preset mode"
|
||||
"name": "Set climate-control device preset mode"
|
||||
},
|
||||
"set_swing_horizontal_mode": {
|
||||
"description": "Sets the horizontal swing mode of a thermostat.",
|
||||
"description": "Sets the horizontal swing mode of a climate-control device.",
|
||||
"fields": {
|
||||
"swing_horizontal_mode": {
|
||||
"description": "Horizontal swing operation mode.",
|
||||
"name": "Horizontal swing mode"
|
||||
}
|
||||
},
|
||||
"name": "Set thermostat horizontal swing mode"
|
||||
"name": "Set climate-control device horizontal swing mode"
|
||||
},
|
||||
"set_swing_mode": {
|
||||
"description": "Sets the swing mode of a thermostat.",
|
||||
"description": "Sets the swing mode of a climate-control device.",
|
||||
"fields": {
|
||||
"swing_mode": {
|
||||
"description": "Swing operation mode.",
|
||||
"name": "Swing mode"
|
||||
}
|
||||
},
|
||||
"name": "Set thermostat swing mode"
|
||||
"name": "Set climate-control device swing mode"
|
||||
},
|
||||
"set_temperature": {
|
||||
"description": "Sets the target temperature of a thermostat.",
|
||||
"description": "Sets the target temperature of a climate-control device.",
|
||||
"fields": {
|
||||
"hvac_mode": {
|
||||
"description": "HVAC operation mode.",
|
||||
@@ -367,25 +363,25 @@
|
||||
"name": "Target temperature"
|
||||
}
|
||||
},
|
||||
"name": "Set thermostat target temperature"
|
||||
"name": "Set climate-control device target temperature"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "Toggles a thermostat on/off.",
|
||||
"name": "Toggle thermostat"
|
||||
"description": "Toggles a climate-control device on/off.",
|
||||
"name": "Toggle climate-control device"
|
||||
},
|
||||
"turn_off": {
|
||||
"description": "Turns off a thermostat.",
|
||||
"name": "Turn off thermostat"
|
||||
"description": "Turns off a climate-control device.",
|
||||
"name": "Turn off climate-control device"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Turns on a thermostat.",
|
||||
"name": "Turn on thermostat"
|
||||
"description": "Turns on a climate-control device.",
|
||||
"name": "Turn on climate-control device"
|
||||
}
|
||||
},
|
||||
"title": "Climate",
|
||||
"triggers": {
|
||||
"hvac_mode_changed": {
|
||||
"description": "Triggers after the mode of one or more thermostats changes.",
|
||||
"description": "Triggers after the mode of one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
@@ -398,10 +394,10 @@
|
||||
"name": "Modes"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat mode changed"
|
||||
"name": "Climate-control device mode changed"
|
||||
},
|
||||
"started_cooling": {
|
||||
"description": "Triggers after one or more thermostats start cooling.",
|
||||
"description": "Triggers after one or more climate-control devices start cooling.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
@@ -410,10 +406,10 @@
|
||||
"name": "[%key:component::climate::common::trigger_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat started cooling"
|
||||
"name": "Climate-control device started cooling"
|
||||
},
|
||||
"started_drying": {
|
||||
"description": "Triggers after one or more thermostats start drying.",
|
||||
"description": "Triggers after one or more climate-control devices start drying.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
@@ -422,10 +418,10 @@
|
||||
"name": "[%key:component::climate::common::trigger_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat started drying"
|
||||
"name": "Climate-control device started drying"
|
||||
},
|
||||
"started_heating": {
|
||||
"description": "Triggers after one or more thermostats start heating.",
|
||||
"description": "Triggers after one or more climate-control devices start heating.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
@@ -434,19 +430,19 @@
|
||||
"name": "[%key:component::climate::common::trigger_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat started heating"
|
||||
"name": "Climate-control device started heating"
|
||||
},
|
||||
"target_humidity_changed": {
|
||||
"description": "Triggers after the humidity setpoint of one or more thermostats changes.",
|
||||
"description": "Triggers after the humidity setpoint of one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"name": "[%key:component::climate::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat target humidity changed"
|
||||
"name": "Climate-control device target humidity changed"
|
||||
},
|
||||
"target_humidity_crossed_threshold": {
|
||||
"description": "Triggers after the humidity setpoint of one or more thermostats crosses a threshold.",
|
||||
"description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
@@ -458,19 +454,19 @@
|
||||
"name": "[%key:component::climate::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat target humidity crossed threshold"
|
||||
"name": "Climate-control device target humidity crossed threshold"
|
||||
},
|
||||
"target_temperature_changed": {
|
||||
"description": "Triggers after the temperature setpoint of one or more thermostats changes.",
|
||||
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"name": "[%key:component::climate::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat target temperature changed"
|
||||
"name": "Climate-control device target temperature changed"
|
||||
},
|
||||
"target_temperature_crossed_threshold": {
|
||||
"description": "Triggers after the temperature setpoint of one or more thermostats crosses a threshold.",
|
||||
"description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
@@ -482,10 +478,10 @@
|
||||
"name": "[%key:component::climate::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat target temperature crossed threshold"
|
||||
"name": "Climate-control device target temperature crossed threshold"
|
||||
},
|
||||
"turned_off": {
|
||||
"description": "Triggers after one or more thermostats turn off.",
|
||||
"description": "Triggers after one or more climate-control devices turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
@@ -494,10 +490,10 @@
|
||||
"name": "[%key:component::climate::common::trigger_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat turned off"
|
||||
"name": "Climate-control device turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers after one or more thermostats turn on, regardless of the mode.",
|
||||
"description": "Triggers after one or more climate-control devices turn on, regardless of the mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
@@ -506,7 +502,7 @@
|
||||
"name": "[%key:component::climate::common::trigger_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Thermostat turned on"
|
||||
"name": "Climate-control device turned on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,8 +169,6 @@ class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
# Polling interval is user-configurable, which is no longer allowed
|
||||
# pylint: disable-next=hass-config-flow-polling-field
|
||||
vol.Optional(
|
||||
CONF_SCAN_INTERVAL,
|
||||
default=self.config_entry.options.get(
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
SerialPortSelector,
|
||||
SerialSelector,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
@@ -110,7 +110,7 @@ class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
translation_key="model",
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_DEVICE): SerialPortSelector(),
|
||||
vol.Required(CONF_DEVICE): SerialSelector(),
|
||||
}
|
||||
),
|
||||
user_input or {},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Data used by this integration."""
|
||||
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Wrapper for media_source around async_upnp_client's DmsDevice ."""
|
||||
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Integration for doorbell triggers."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "doorbell"
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
__all__ = []
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
return True
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"triggers": {
|
||||
"rang": {
|
||||
"trigger": "mdi:doorbell"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"domain": "doorbell",
|
||||
"name": "Doorbell",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/doorbell",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"title": "Doorbell",
|
||||
"triggers": {
|
||||
"rang": {
|
||||
"description": "Triggers after one or more doorbells rang.",
|
||||
"name": "Doorbell rang"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Provides triggers for doorbells."""
|
||||
|
||||
from homeassistant.components.event import (
|
||||
ATTR_EVENT_TYPE,
|
||||
DOMAIN as EVENT_DOMAIN,
|
||||
DoorbellEventType,
|
||||
EventDeviceClass,
|
||||
)
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
)
|
||||
|
||||
|
||||
class DoorbellRangTrigger(EntityTriggerBase):
|
||||
"""Trigger for doorbell event entity when a ring event is received."""
|
||||
|
||||
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the entity is available and the event type is ring."""
|
||||
return (
|
||||
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
|
||||
)
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state is valid and different from the current state."""
|
||||
|
||||
# UNKNOWN is a valid from_state, otherwise the first time the event is received
|
||||
# would not trigger
|
||||
if from_state.state == STATE_UNAVAILABLE:
|
||||
return False
|
||||
|
||||
return from_state.state != to_state.state
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"rang": DoorbellRangTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for doorbells."""
|
||||
return TRIGGERS
|
||||
@@ -1,5 +0,0 @@
|
||||
rang:
|
||||
target:
|
||||
entity:
|
||||
domain: event
|
||||
device_class: doorbell
|
||||
@@ -13,7 +13,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -36,27 +35,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_host: str
|
||||
_box_name: str
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle DHCP discovery."""
|
||||
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
|
||||
|
||||
try:
|
||||
box_name, _ = await self._validate_input(discovery_info.ip)
|
||||
except DucoConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except DucoError:
|
||||
_LOGGER.exception("Unexpected error discovering Duco box via DHCP")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
self._host = discovery_info.ip
|
||||
self._box_name = box_name
|
||||
self.context["title_placeholders"] = {"name": box_name}
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -64,7 +64,6 @@ async def async_setup_entry(
|
||||
"""Set up Duco fan entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# BOX is always node 1 and is never dynamically added or removed, so no listener needed.
|
||||
async_add_entities(
|
||||
DucoVentilationFanEntity(coordinator, node)
|
||||
for node in coordinator.data.nodes.values()
|
||||
|
||||
@@ -3,17 +3,12 @@
|
||||
"name": "Duco",
|
||||
"codeowners": ["@ronaldvdmeer"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "duco_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/duco",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-client==0.3.4"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-duco-client==0.3.2"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
|
||||
|
||||
@@ -55,7 +55,11 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
dynamic-devices:
|
||||
status: todo
|
||||
comment: >-
|
||||
Users can pair new modules (CO2 sensors, humidity sensors, zone valves)
|
||||
to their Duco box. Dynamic device support to be added in a follow-up PR.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -70,7 +74,11 @@ rules:
|
||||
handled by the coordinator (unavailable entities) and resolve automatically.
|
||||
There are no credentials to expire and no versioned API to become
|
||||
incompatible with.
|
||||
stale-devices: done
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: >-
|
||||
To be implemented together with dynamic device support in a follow-up PR.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from duco.models import Node, NodeType, VentilationState
|
||||
|
||||
@@ -20,16 +19,12 @@ from homeassistant.const import (
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
from .entity import DucoEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@@ -82,7 +77,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda node: node.sensor.rh if node.sensor else None,
|
||||
node_types=(NodeType.BSRH, NodeType.UCRH),
|
||||
node_types=(NodeType.BSRH,),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="iaq_rh",
|
||||
@@ -91,7 +86,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda node: node.sensor.iaq_rh if node.sensor else None,
|
||||
node_types=(NodeType.BSRH, NodeType.UCRH),
|
||||
node_types=(NodeType.BSRH,),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -116,59 +111,22 @@ async def async_setup_entry(
|
||||
"""Set up Duco sensor entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Track the node IDs for which entities have already been created, so we
|
||||
# can detect both newly added and stale (deregistered) nodes on every
|
||||
# coordinator update.
|
||||
known_nodes: set[int] = set()
|
||||
|
||||
@callback
|
||||
def _async_add_new_entities() -> None:
|
||||
# Remove devices whose nodes have disappeared from the API.
|
||||
# The firmware removes deregistered RF/wired nodes automatically.
|
||||
# BSRH box sensors that are physically unplugged from the PCB are
|
||||
# not deregistered by the firmware and will never appear here as stale.
|
||||
stale_node_ids = known_nodes - coordinator.data.nodes.keys()
|
||||
if stale_node_ids:
|
||||
device_reg = dr.async_get(hass)
|
||||
mac = entry.unique_id
|
||||
for node_id in stale_node_ids:
|
||||
device = device_reg.async_get_device(
|
||||
identifiers={(DOMAIN, f"{mac}_{node_id}")}
|
||||
)
|
||||
if device:
|
||||
device_reg.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
)
|
||||
known_nodes.difference_update(stale_node_ids)
|
||||
|
||||
new_entities: list[SensorEntity] = []
|
||||
for node in coordinator.data.nodes.values():
|
||||
if node.node_id in known_nodes:
|
||||
continue
|
||||
known_nodes.add(node.node_id)
|
||||
if node.general.node_type == NodeType.UNKNOWN:
|
||||
_LOGGER.warning(
|
||||
"Duco node %s (%s) has an unsupported device type and will be ignored",
|
||||
node.node_id,
|
||||
node.general.name,
|
||||
)
|
||||
continue
|
||||
new_entities.extend(
|
||||
async_add_entities(
|
||||
[
|
||||
*[
|
||||
DucoSensorEntity(coordinator, node, description)
|
||||
for node in coordinator.data.nodes.values()
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if node.general.node_type in description.node_types
|
||||
)
|
||||
new_entities.extend(
|
||||
],
|
||||
*[
|
||||
DucoBoxSensorEntity(coordinator, node, description)
|
||||
for node in coordinator.data.nodes.values()
|
||||
for description in BOX_SENSOR_DESCRIPTIONS
|
||||
if node.general.node_type == NodeType.BOX
|
||||
)
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
|
||||
_async_add_new_entities()
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DucoSensorEntity(DucoEntity, SensorEntity):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""The EARN-E P1 Meter integration."""
|
||||
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -8,24 +8,18 @@ from aioesphomeapi import APIClient, APIConnectionError
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.components.bluetooth import async_remove_scanner
|
||||
from homeassistant.components.usb import (
|
||||
SerialDevice,
|
||||
USBDevice,
|
||||
async_register_serial_port_scanner,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
__version__ as ha_version,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import async_delete_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import assist_satellite, dashboard, ffmpeg_proxy, serial_proxy
|
||||
from . import assist_satellite, dashboard, ffmpeg_proxy
|
||||
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
|
||||
from .domain_data import DomainData
|
||||
from .encryption_key_storage import async_get_encryption_key_storage
|
||||
@@ -40,48 +34,12 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
CLIENT_INFO = f"Home Assistant {ha_version}"
|
||||
|
||||
|
||||
@callback
|
||||
def _async_scan_serial_ports(
|
||||
hass: HomeAssistant,
|
||||
) -> list[USBDevice | SerialDevice]:
|
||||
"""Return serial-proxy ports exposed by connected ESPHome devices."""
|
||||
ports: list[USBDevice | SerialDevice] = []
|
||||
|
||||
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
entry_data = entry.runtime_data
|
||||
if not entry_data.available:
|
||||
continue
|
||||
|
||||
device_info = entry_data.device_info
|
||||
if device_info is None:
|
||||
continue
|
||||
|
||||
ports.extend(
|
||||
SerialDevice(
|
||||
device=str(serial_proxy.build_url(entry.entry_id, proxy.name)),
|
||||
serial_number=(
|
||||
device_info.mac_address.replace(":", "") + "-" + slugify(proxy.name)
|
||||
),
|
||||
manufacturer=device_info.manufacturer,
|
||||
description=f"{device_info.model} ({proxy.name})",
|
||||
)
|
||||
for proxy in device_info.serial_proxies
|
||||
)
|
||||
|
||||
return ports
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the esphome component."""
|
||||
ffmpeg_proxy.async_setup(hass)
|
||||
await assist_satellite.async_setup(hass)
|
||||
await dashboard.async_setup(hass)
|
||||
async_setup_websocket_api(hass)
|
||||
|
||||
if "usb" in hass.config.components:
|
||||
async_register_serial_port_scanner(hass, _async_scan_serial_ports)
|
||||
serial_proxy.set_hass_loop(hass.loop)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,5 @@ class DomainData:
|
||||
@cache
|
||||
def get(cls, hass: HomeAssistant) -> Self:
|
||||
"""Get the global DomainData instance stored in hass.data."""
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=hass-use-runtime-data
|
||||
ret = hass.data[DOMAIN] = cls()
|
||||
return ret
|
||||
|
||||
@@ -35,7 +35,11 @@ class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEn
|
||||
@convert_api_error_ha_error
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command."""
|
||||
timings = command.get_raw_timings()
|
||||
timings = [
|
||||
interval
|
||||
for timing in command.get_raw_timings()
|
||||
for interval in (timing.high_us, -timing.low_us)
|
||||
]
|
||||
_LOGGER.debug("Sending command: %s", timings)
|
||||
|
||||
self._client.infrared_rf_transmit_raw_timings(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "esphome",
|
||||
"name": "ESPHome",
|
||||
"after_dependencies": ["hassio", "tag", "usb", "zeroconf"],
|
||||
"after_dependencies": ["hassio", "zeroconf", "tag"],
|
||||
"codeowners": ["@jesserockz", "@kbx81", "@bdraco"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"],
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.18.0",
|
||||
"aioesphomeapi==44.13.3",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.3"
|
||||
],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user