mirror of
https://github.com/home-assistant/core.git
synced 2026-06-11 19:51:39 +02:00
Compare commits
3 Commits
dev
..
add-naive-now
| Author | SHA1 | Date | |
|---|---|---|---|
| 42308934e1 | |||
| 62ca3d30d3 | |||
| 991a05f797 |
+19
-17
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -59,13 +59,15 @@ permissions: {}
|
||||
|
||||
concurrency:
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
run-name: "Check requirements (AW)"
|
||||
|
||||
jobs:
|
||||
activation:
|
||||
needs: pre_activation
|
||||
needs:
|
||||
- extract_pr_number
|
||||
- pre_activation
|
||||
# zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation
|
||||
if: >
|
||||
(needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id &&
|
||||
@@ -189,20 +191,20 @@ jobs:
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment, missing_tool, missing_data, noop
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -231,12 +233,12 @@ jobs:
|
||||
{{/if}}
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF'
|
||||
cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/check-requirements.md}}
|
||||
GH_AW_PROMPT_2fc32253e89940f3_EOF
|
||||
GH_AW_PROMPT_198418d99edc7d5b_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -321,6 +323,7 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: "gh-aw-copilot-${{ github.workflow }}"
|
||||
@@ -450,9 +453,9 @@ jobs:
|
||||
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
|
||||
mkdir -p /tmp/gh-aw/safeoutputs
|
||||
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_eaae5443153d0b45_EOF'
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF'
|
||||
{"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_eaae5443153d0b45_EOF
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -644,7 +647,7 @@ jobs:
|
||||
|
||||
mkdir -p /home/runner/.copilot
|
||||
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
|
||||
cat << GH_AW_MCP_CONFIG_d99df59573a98681_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
@@ -654,7 +657,7 @@ jobs:
|
||||
"GITHUB_HOST": "\${GITHUB_SERVER_URL}",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
|
||||
"GITHUB_READ_ONLY": "1",
|
||||
"GITHUB_TOOLSETS": "repos,pull_requests"
|
||||
"GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions"
|
||||
},
|
||||
"guard-policies": {
|
||||
"allow-only": {
|
||||
@@ -688,7 +691,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_d99df59573a98681_EOF
|
||||
GH_AW_MCP_CONFIG_175174907e5a28b4_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -1281,7 +1284,6 @@ jobs:
|
||||
}
|
||||
|
||||
extract_pr_number:
|
||||
needs: activation
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
network:
|
||||
allowed:
|
||||
@@ -13,7 +14,7 @@ network:
|
||||
tools:
|
||||
web-fetch: {}
|
||||
github:
|
||||
toolsets: [repos, pull_requests]
|
||||
toolsets: [default, actions]
|
||||
min-integrity: unapproved
|
||||
safe-outputs:
|
||||
add-comment:
|
||||
@@ -43,7 +44,7 @@ jobs:
|
||||
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
|
||||
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.id }}
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
@@ -82,289 +83,296 @@ description: >
|
||||
|
||||
# Check requirements (AW)
|
||||
|
||||
You are a code-review assistant for Home Assistant. The deterministic
|
||||
stage already evaluated every check it can and produced an artifact at
|
||||
`/tmp/gh-aw/deterministic/results.json`. Your only job is to resolve any
|
||||
`needs_agent` checks and post the rendered comment.
|
||||
You are a code review assistant for the Home Assistant project. The
|
||||
deterministic stage has already evaluated every check it can on its own
|
||||
and produced an artifact containing the PR number, per-package check
|
||||
results, and a pre-rendered comment with placeholders. **Your only job is
|
||||
to read that artifact, resolve any `needs_agent` checks, and post the
|
||||
final comment.**
|
||||
|
||||
## Step 1 — Read the artifact
|
||||
## Step 1 — Read the deterministic-stage artifact
|
||||
|
||||
Read the JSON directly for the full schema. Key fields:
|
||||
The deterministic stage uploaded its results to the runner at
|
||||
`/tmp/gh-aw/deterministic/results.json`.
|
||||
|
||||
- `pr_number`, `needs_agent` (bool), `packages[]`, `rendered_comment`.
|
||||
- Each `package`: `name`, `old_version` (`null` if new), `new_version`,
|
||||
`repo_url`, `publisher_kind`, `checks` (keyed by check-kind, each
|
||||
with `status` of `pass`/`warn`/`fail`/`needs_agent` and `details`).
|
||||
- `rendered_comment` contains, for each `needs_agent` check, two
|
||||
placeholders to replace:
|
||||
- `{{CHECK_CELL:<pkg>:<kind>}}` → exactly one of `✅`, `☑️`, `⚠️`, `❌`. The
|
||||
**`security`** check kind uses `☑️` instead of `✅` for the success
|
||||
case — see its section below for why.
|
||||
- `{{CHECK_DETAIL:<pkg>:<kind>}}` → `<icon> <one-line explanation>`
|
||||
(the bullet's `- **<label>**:` prefix is already rendered; replace
|
||||
only the placeholder).
|
||||
The JSON has this shape:
|
||||
|
||||
Do not modify other content in `rendered_comment`, do not re-evaluate
|
||||
deterministic checks, do not add or remove packages. If `needs_agent`
|
||||
is `false`, emit `rendered_comment` unchanged.
|
||||
- `pr_number` — the PR being checked. The `add_comment` safe-output is
|
||||
already targeted at this PR (a pre-job extracts `pr_number` from the
|
||||
artifact and the workflow wires it into the safe-output config via
|
||||
`needs.extract_pr_number.outputs.pr_number`), so **you do not need to
|
||||
set `item_number` yourself** — just emit `add_comment` with the
|
||||
rendered body.
|
||||
- `needs_agent` — `true` iff any package's check needs resolution.
|
||||
- `packages[]` — one entry per changed package. Each entry has:
|
||||
- `name`, `old_version` (`null` for a newly added package; otherwise the
|
||||
previous pin), `new_version`, `repo_url`, `publisher_kind`.
|
||||
- `checks` — a dict keyed by **check kind** (string). Each value has a
|
||||
`status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`.
|
||||
- `rendered_comment` — the final PR comment body, already rendered. For
|
||||
every check whose status is `needs_agent` it contains two placeholders
|
||||
you must replace:
|
||||
- `{{CHECK_CELL:<pkg-name>:<check-kind>}}` — one cell of the summary
|
||||
table. Replace with exactly one of `✅`, `⚠️`, `❌`.
|
||||
- `{{CHECK_DETAIL:<pkg-name>:<check-kind>}}` — the body of one bullet
|
||||
in the package's `<details>` block. Replace with
|
||||
`<icon> <one-line explanation>` (the bullet's leading
|
||||
`- **<label>**:` is already rendered — replace only the placeholder).
|
||||
|
||||
You **must not** modify any other content in `rendered_comment`. Do not
|
||||
re-evaluate checks that already have a deterministic status. Do not add
|
||||
or remove packages.
|
||||
|
||||
## Step 2 — Resolve each `needs_agent` check
|
||||
|
||||
For each `(package, check_kind)` with `status == "needs_agent"`, find
|
||||
the matching `### Check kind: <check_kind>` section below and follow
|
||||
it. If no section matches, emit a single `add_comment` with:
|
||||
For each `package` in `packages`:
|
||||
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
For each `(check_kind, result)` in `package.checks` where
|
||||
`result.status == "needs_agent"`:
|
||||
|
||||
❌ Internal error: deterministic artifact contains an unknown check kind
|
||||
(`<check_kind>` on `<pkg>`).
|
||||
```
|
||||
1. Look up `## Check kind: <check_kind>` in the **Check instructions**
|
||||
section below.
|
||||
2. **If no matching section exists**: emit a single `add_comment` whose
|
||||
body is:
|
||||
|
||||
Then stop. Do not improvise a verdict.
|
||||
```
|
||||
<!-- requirements-check -->
|
||||
## Check requirements
|
||||
|
||||
❌ Internal error: the deterministic artifact contains a check kind
|
||||
(`<check_kind>` on package `<pkg-name>`) that this workflow has no
|
||||
instructions for. Update `.github/workflows/check-requirements.md`
|
||||
to add a matching `## Check kind: <check_kind>` section, or remove
|
||||
the kind from the deterministic stage.
|
||||
```
|
||||
|
||||
Then stop. **Do not improvise** a verdict for an unknown check kind.
|
||||
3. Otherwise, follow the instructions in that section. They tell you
|
||||
which icon (✅/⚠️/❌) and one-line explanation to produce.
|
||||
|
||||
## Step 3 — Post the comment
|
||||
|
||||
Replace every placeholder with the resolved value and emit
|
||||
`rendered_comment` via `add_comment`. Preserve the leading
|
||||
`<!-- requirements-check -->` marker. The PR target is already wired;
|
||||
do not pass `item_number`.
|
||||
1. Replace every `{{CHECK_CELL:…}}` and `{{CHECK_DETAIL:…}}` placeholder
|
||||
in `rendered_comment` with the resolved value.
|
||||
2. Emit the resulting markdown using `add_comment` — set `body` to the
|
||||
merged `rendered_comment` verbatim (the leading
|
||||
`<!-- requirements-check -->` marker must be preserved). The PR
|
||||
target is already set by the workflow; do not pass `item_number`.
|
||||
|
||||
If the artifact's top-level `needs_agent` is `false` (no checks need
|
||||
you), emit `rendered_comment` unchanged.
|
||||
|
||||
## Check instructions
|
||||
|
||||
### Check kind: `repo_public`
|
||||
|
||||
`web-fetch` GET `package.repo_url`.
|
||||
- 200 + public repo page → ✅ `<repo_url> is publicly accessible.`
|
||||
- 4xx/5xx or login redirect → ❌ `Source repository at <repo_url> is
|
||||
not publicly accessible. Home Assistant requires dependencies to
|
||||
have publicly available source code.`
|
||||
- Otherwise → ⚠️ with a one-line description.
|
||||
Verify that the package's source repository is publicly reachable.
|
||||
|
||||
If ❌, also mark this package's `release_pipeline` and `async_blocking`
|
||||
cells/details as `—` and explain `Skipped because the source
|
||||
repository is not publicly accessible.`.
|
||||
1. Read `package.repo_url`.
|
||||
2. Use the `web-fetch` tool to GET that URL.
|
||||
3. Decide the verdict:
|
||||
- HTTP 200, returns a public repository page → ✅
|
||||
`<repo_url> is publicly accessible.`
|
||||
- HTTP 4xx/5xx, or the response redirects to a login / sign-in page →
|
||||
❌ `Source repository at <repo_url> is not publicly accessible.
|
||||
Home Assistant requires all dependencies to have publicly available
|
||||
source code.`
|
||||
- Any other inconclusive result → ⚠️ with a one-line description.
|
||||
|
||||
If `repo_public` resolves to ❌ for a package, **also** mark that
|
||||
package's `release_pipeline` and `async_blocking` cells/details as `—`
|
||||
(em dash) and explain `Skipped because the source repository is not
|
||||
publicly accessible.` — neither check can be performed without a public
|
||||
repo.
|
||||
|
||||
### Check kind: `pr_link`
|
||||
|
||||
Fetch the PR body via the `pull_requests` MCP using `pr_number`. Extract URLs.
|
||||
Verify the PR description contains the right link for the change.
|
||||
|
||||
- **New package** (`old_version == null`): body must contain a URL
|
||||
pointing at `repo_url`'s `owner/repo` on the same host (any
|
||||
sub-path OK). PyPI is not sufficient.
|
||||
- ✅ if present; otherwise ❌ `PR description must link to the
|
||||
source repository at <repo_url>. A PyPI page link is not
|
||||
sufficient.`
|
||||
- **Version bump**: body must contain a URL on the same host as
|
||||
`repo_url` that mentions **both** `old_version` and `new_version`
|
||||
(compare URL, changelog, release page).
|
||||
- ✅ if present and versions match; otherwise ❌ `PR description
|
||||
should link to a changelog or compare URL on <repo_url> that
|
||||
mentions both <old_version> and <new_version>.`
|
||||
1. Fetch the PR body via the GitHub MCP tool, using the `pr_number`
|
||||
field from the artifact.
|
||||
2. Extract all URLs from the body.
|
||||
3. For a **new package** (`package.old_version` is `null`):
|
||||
- The PR body must contain a URL that points at `package.repo_url`
|
||||
(any sub-path of the same `owner/repo` on the same host is
|
||||
acceptable). A PyPI link is **not** sufficient.
|
||||
- ✅ if such a URL is present.
|
||||
- ❌ otherwise:
|
||||
`PR description must link to the source repository at <repo_url>.
|
||||
A PyPI page link is not sufficient.`
|
||||
4. For a **version bump** (`package.old_version` is not `null`):
|
||||
- The PR body must contain a URL on the same host as
|
||||
`package.repo_url` that references **both** `package.old_version`
|
||||
and `package.new_version` (e.g. a GitHub compare URL
|
||||
`compare/vX...vY`, a release / changelog URL containing both
|
||||
versions, etc.).
|
||||
- ✅ if such a URL is present and the versions match the actual bump.
|
||||
- ❌ otherwise:
|
||||
`PR description should link to a changelog or compare URL on
|
||||
<repo_url> that mentions both <old_version> and <new_version>.`
|
||||
|
||||
### Check kind: `release_pipeline`
|
||||
|
||||
Inspect the upstream's publish-to-PyPI CI. Host-specific lookup, same
|
||||
rubric:
|
||||
Inspect the upstream project's release / publish CI pipeline.
|
||||
|
||||
1. Locate the publish workflow / job (name or filename contains
|
||||
`release`, `publish`, `pypi`, or `deploy`).
|
||||
- GitHub: list `.github/workflows/` via the `repos` MCP, pick the
|
||||
promising file by name, fetch its contents.
|
||||
- GitLab: fetch `.gitlab-ci.yml` from the default ref via
|
||||
`https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
|
||||
- Other hosts: `web-fetch` an obvious CI config
|
||||
(`.circleci/config.yml`, `bitbucket-pipelines.yml`, etc.).
|
||||
2. Apply this rubric:
|
||||
- **Trigger**: tag push / `release: published` / protected branch —
|
||||
not solely manual dispatch without an environment guard.
|
||||
- **Credentials**: OIDC (`id-token: write` +
|
||||
`pypa/gh-action-pypi-publish` or equivalent) preferred; static
|
||||
`PYPI_TOKEN` from a CI secret acceptable for a bump.
|
||||
- **No bypass**: no ungated `twine upload` / `pip upload`.
|
||||
3. Verdict:
|
||||
- ✅ — OIDC + sane triggers + no bypass.
|
||||
- ⚠️ — static token on a bump, details unclear, or
|
||||
non-GitHub/GitLab host with limited CI visibility.
|
||||
- ❌ — static token on a new package, or manual-only triggers
|
||||
without environment protection.
|
||||
For each package needing inspection, determine the source repository
|
||||
host from `package.repo_url`, then apply the corresponding checklist.
|
||||
|
||||
#### GitHub repositories (`github.com`)
|
||||
|
||||
1. List workflows: `GET /repos/{owner}/{repo}/actions/workflows`.
|
||||
2. Identify any workflow whose name or filename suggests publishing to
|
||||
PyPI (`release`, `publish`, `pypi`, or `deploy`).
|
||||
3. Fetch the workflow file and check:
|
||||
- **Trigger sanity**: triggered by `push` to tags,
|
||||
`release: published`, or `workflow_run` on a release job —
|
||||
**not** solely `workflow_dispatch` with no environment-protection
|
||||
guard.
|
||||
- **OIDC / Trusted Publisher**: look for `id-token: write` and one of
|
||||
`pypa/gh-action-pypi-publish`, `actions/attest-build-provenance`,
|
||||
or `TWINE_PASSWORD` from a static `secrets.PYPI_TOKEN`.
|
||||
- **No manual upload bypass**: no ungated `twine upload` or
|
||||
`pip upload`.
|
||||
4. Verdict:
|
||||
- ✅ if OIDC + sane triggers + no bypass.
|
||||
- ⚠️ if static token but version bump, or details unclear.
|
||||
- ❌ if static token on a new package, or only-manual triggers with
|
||||
no environment protection.
|
||||
|
||||
#### GitLab repositories (`gitlab.com` or self-hosted GitLab)
|
||||
|
||||
1. Resolve the project ID via
|
||||
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`.
|
||||
2. Fetch `.gitlab-ci.yml` via
|
||||
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
|
||||
3. Apply the same conceptual checks: tag-only / protected-branch
|
||||
triggers, GitLab OIDC `id_tokens` or CI/CD protected `PYPI_TOKEN`, no
|
||||
ungated `twine upload`. Same verdict rules as GitHub.
|
||||
|
||||
#### Other code hosting providers (Bitbucket, Codeberg, Gitea, Sourcehut, …)
|
||||
|
||||
1. Use `web-fetch` to retrieve any visible CI configuration
|
||||
(`.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml`,
|
||||
`bitbucket-pipelines.yml`, `.builds/*.yml`).
|
||||
2. Apply the conceptual checks: automated triggers, CI-injected
|
||||
credentials, no manual `twine upload`.
|
||||
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
|
||||
inspected; hosting provider is not GitHub or GitLab.`
|
||||
|
||||
### Check kind: `async_blocking`
|
||||
|
||||
Verify the dependency does not call blocking APIs inside `async def`
|
||||
bodies. Home Assistant runs on a single asyncio loop, so blocking
|
||||
calls from the async surface stall the whole loop. A purely sync
|
||||
library is fine — integrations wrap its calls in an executor.
|
||||
Verify whether the dependency performs blocking I/O inside async code
|
||||
paths. Home Assistant runs on a single asyncio event loop, so a library
|
||||
that exposes an `async` surface must not call blocking APIs from inside
|
||||
its `async def` functions — that stalls the whole loop. A purely sync
|
||||
library is fine: Home Assistant integrations are expected to wrap such
|
||||
calls in an executor.
|
||||
|
||||
**Mode** (decided by `old_version`):
|
||||
- `null` → new package: review the entire current source tree.
|
||||
- string → version bump: review only the diff between the two tags.
|
||||
Blocking calls already present in `old_version` are not regressions.
|
||||
**Two modes — pick by inspecting `package.old_version`:**
|
||||
|
||||
**Step 1 — async surface?**
|
||||
- `old_version` is `null` → **new package**: review the *entire current
|
||||
source tree*. Nothing about this dependency has been vetted before.
|
||||
- `old_version` is a string → **version bump**: review only the *diff
|
||||
between `old_version` and `new_version`*. The previous version was
|
||||
already accepted, so blocking calls that were present in
|
||||
`old_version` are not regressions; report only what `new_version`
|
||||
introduces.
|
||||
|
||||
Fetch `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` at the
|
||||
tag matching `new_version` (try `v{version}`, `{version}`,
|
||||
`release-{version}` — at most three attempts). Use the `repos` MCP for
|
||||
github.com, `web-fetch` otherwise.
|
||||
#### Step 1 — Decide whether the library exposes an async surface
|
||||
|
||||
If sync-only (no `async def` in public modules; no
|
||||
asyncio/aiohttp/httpx/anyio in deps; no `Framework :: AsyncIO`
|
||||
classifier) → ✅ `Sync-only library; Home Assistant integrations must
|
||||
wrap calls in an executor.` (Same verdict for both modes.)
|
||||
Use the `github` MCP tool (for `github.com` repos) or `web-fetch`
|
||||
(other hosts) on `package.repo_url`. Always inspect the tag /
|
||||
ref matching `new_version` (e.g. `v{new_version}` or `{new_version}`).
|
||||
|
||||
**Step 2 — review the surface**
|
||||
- Locate the top-level package directory (usually named after the
|
||||
import name, often equal or close to `package.name`).
|
||||
- Check `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` for
|
||||
async indicators (`Framework :: AsyncIO` trove classifier, `asyncio`
|
||||
/ `aiohttp` / `httpx` / `anyio` in dependencies, an async usage
|
||||
example in the README).
|
||||
- Grep the package source for `async def`. A handful of `async def`
|
||||
entries in the public modules is enough to treat the library as
|
||||
having an async surface.
|
||||
|
||||
- New package: grep public modules for `async def`, inspect each
|
||||
async body and transitive helpers.
|
||||
- Bump: fetch the compare diff
|
||||
(`/repos/{owner}/{repo}/compare/{old}...{new}` on GitHub, equivalent
|
||||
on GitLab/other hosts). Only flag patterns on **added** lines that
|
||||
are inside or reachable from `async def`. If no tag format resolves,
|
||||
fall back to a full review and note that the diff was unavailable.
|
||||
If the library is **sync-only** (no `async def` in its public modules
|
||||
and no async framework dependency) → ✅
|
||||
`Sync-only library; Home Assistant integrations must wrap calls in an
|
||||
executor.` *This verdict is the same in both modes.*
|
||||
|
||||
**Blocking patterns to flag inside `async def`:**
|
||||
#### Step 2a — Mode: new package (`old_version` is `null`)
|
||||
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(`, `pycurl`.
|
||||
- `time.sleep(` (use `await asyncio.sleep(`).
|
||||
- Sync sockets/SSL: bare `socket.socket` I/O, `ssl.wrap_socket`,
|
||||
Inspect **every `async def` in the public modules** for blocking
|
||||
patterns. Walk transitively into helpers the async functions call.
|
||||
|
||||
#### Step 2b — Mode: version bump (`old_version` is a string)
|
||||
|
||||
Fetch the diff between the two tags and review **only changed lines**:
|
||||
|
||||
- GitHub: `GET /repos/{owner}/{repo}/compare/{old_tag}...{new_tag}` via
|
||||
the `github` MCP tool, or
|
||||
`https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag}.diff`
|
||||
via `web-fetch`. Try the common tag formats in order until one
|
||||
resolves: `v{version}`, `{version}`, `release-{version}`.
|
||||
- GitLab: `https://gitlab.com/{namespace}/{project}/-/compare/{old_tag}...{new_tag}.diff`.
|
||||
- Other hosts: use the project's equivalent compare URL via
|
||||
`web-fetch`.
|
||||
|
||||
If neither tag format resolves on the host, fall back to a full review
|
||||
(Step 2a) and mention in the detail that the diff was unavailable.
|
||||
|
||||
When reviewing the diff, only flag blocking patterns that appear in
|
||||
**added lines** *inside or reachable from* an `async def`. A blocking
|
||||
call that existed in `old_version` and is unchanged is not a regression
|
||||
for this bump.
|
||||
|
||||
#### Step 3 — Blocking patterns to look for
|
||||
|
||||
In both modes, the patterns to flag inside `async def` bodies are:
|
||||
|
||||
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
|
||||
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
|
||||
`AsyncClient`), `pycurl`.
|
||||
- `time.sleep(` (must be `await asyncio.sleep(`).
|
||||
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
|
||||
blocking `select.select`.
|
||||
- File I/O on the request path: `open(` /
|
||||
`pathlib.Path.read_*` / `.write_*` for non-trivial sizes (small
|
||||
one-shot reads during import are OK).
|
||||
- Sync DB drivers: `sqlite3`, `psycopg2`, `pymysql`, sync `pymongo` /
|
||||
`redis.Redis`.
|
||||
- `subprocess.run` / `subprocess.call` / `os.system`.
|
||||
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
|
||||
non-trivial sizes (small one-shot reads during import are
|
||||
acceptable; reads/writes on the request path are not — prefer
|
||||
`aiofiles` / executor).
|
||||
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
|
||||
`pymongo` (sync client), `redis.Redis` (sync client).
|
||||
- `subprocess.run` / `subprocess.call` / `os.system` (must be
|
||||
`asyncio.create_subprocess_*`).
|
||||
|
||||
Calls dispatched to an executor (`run_in_executor`,
|
||||
`asyncio.to_thread`, `anyio.to_thread.run_sync`) do **not** count as
|
||||
blocking.
|
||||
A call that is clearly dispatched to an executor
|
||||
(`run_in_executor`, `asyncio.to_thread`, `anyio.to_thread.run_sync`)
|
||||
does NOT count as blocking.
|
||||
|
||||
**Verdict:**
|
||||
#### Step 4 — Verdict
|
||||
|
||||
- ✅ — no offending pattern. Bumps: phrase as `No new blocking calls
|
||||
introduced in {old_version} → {new_version}.`.
|
||||
- ⚠️ — blocking only in sync helpers the async API never calls, or
|
||||
clearly off the hot path (e.g. one-shot pre-loop setup). Cite at
|
||||
least one `<file>:<line>` and say why it's not hot.
|
||||
- ❌ — blocking call reachable from a public `async def` on the
|
||||
request/polling path (bump: introduced or moved onto the hot path
|
||||
by this version). Cite the offending `<file>:<line>` as a clickable
|
||||
link on the repo host.
|
||||
|
||||
### Check kind: `security`
|
||||
|
||||
**Baseline** scan of the upstream source for obvious supply-chain red
|
||||
flags — a cheap first pass, **not** a security review or malware audit.
|
||||
A clean result means "nothing obvious stood out", not "this package is
|
||||
safe". The success icon is `☑️` — **never** `✅` — so a passing scan is
|
||||
not read as an endorsement.
|
||||
|
||||
If `repo_public` resolves to ❌ for the same package, mark `security`'s
|
||||
cell and detail as `—` and explain `Skipped because the source
|
||||
repository is not publicly accessible.` — the source cannot be fetched.
|
||||
|
||||
**Step 1 — Fetch a representative slice**
|
||||
|
||||
Locate the source from `package.repo_url`.
|
||||
|
||||
- GitHub: resolve the default branch (`GET /repos/{owner}/{repo}`), list
|
||||
the tree (`GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1`),
|
||||
find the module dir (`{name}/` or `src/{name}/`, normalising `-` ↔ `_`).
|
||||
- GitLab: equivalent REST calls. Other hosts: `web-fetch` raw file URLs.
|
||||
|
||||
Fetch the **raw contents** of `setup.py` (install-time code runs on every
|
||||
consumer), `pyproject.toml` (`[build-system]` / custom backend), the
|
||||
package's `__init__.py`, and co — prioritising `entry_points` targets, plus any name suggesting
|
||||
bootstrap / loader / self-update (`update*.py`, `loader*.py`,
|
||||
`bootstrap*.py`, `_native.py`, `_post_install*.py`, …).
|
||||
|
||||
If the tree is too large for the API budget, inspect at least `setup.py`,
|
||||
`pyproject.toml`, and `__init__.py`, then return ⚠️ noting the partial scan.
|
||||
|
||||
**Step 2 — Patterns to flag**
|
||||
|
||||
Reason from principles, not a fixed checklist: for each file ask *would a
|
||||
well-behaved library doing what this package's PyPI description claims
|
||||
need to do this?* If "no" or "unclear", record a finding. The categories
|
||||
describe the **shape** of concerning behavior; the named APIs, filenames,
|
||||
and keys are illustrative — treat any equivalent construct (including ones
|
||||
that did not exist when this was written) the same way.
|
||||
|
||||
For every finding include the file path, line number, a snippet
|
||||
(≤ 120 chars), a permalink
|
||||
(`https://github.com/{owner}/{repo}/blob/{sha}/{path}#L{line}` or the
|
||||
GitLab equivalent), and one sentence on why it is out of scope.
|
||||
|
||||
1. **Reaches into Home Assistant internals.** A library should touch HA
|
||||
only through its documented Python API — never the `config_dir`
|
||||
filesystem or internal auth / session state. Flag code that opens,
|
||||
reads, writes, or resolves paths to artifacts it does not own
|
||||
(top-level YAML it did not create, anything under `.storage/`, other
|
||||
integrations' files) or reads tokens / refresh tokens / auth providers
|
||||
(e.g. `secrets.yaml`, `.storage/auth*`, `hass.auth`). The principle is
|
||||
*out-of-scope access*, not a static list of names.
|
||||
2. **Network input flows into an execution sink (download-and-execute).**
|
||||
Flag any data-flow from a network response body (any HTTP / WebSocket /
|
||||
raw-socket client, sync or async) to an execution sink: `exec`, `eval`,
|
||||
`compile`, `marshal.loads`, `pickle.loads`, `types.FunctionType`,
|
||||
`importlib.util.spec_from_loader`, `subprocess.*`, `os.system`, shell
|
||||
pipelines (`curl … | sh`), or a file later imported / executed — plus
|
||||
package-manager calls (`pip install` / `download`) with args resolved
|
||||
from network responses at runtime.
|
||||
3. **Build / install-time code is non-deterministic or non-local.**
|
||||
`setup.py`, `setup.cfg` `cmdclass`, custom PEP 517 backends, and other
|
||||
build hooks must only compile and copy files shipped in the sdist. Flag
|
||||
build-stage code that opens a socket, shells out, writes outside the
|
||||
build / install tree, or pulls a build backend not on PyPI (Git URL /
|
||||
local path).
|
||||
4. **Reads secrets and combines them with an egress path.** The shape is
|
||||
*secret-source → outbound-channel*. Flag code that reads credential
|
||||
material (token-like env vars, credential files under the user's home,
|
||||
OS keychain APIs, browser-profile dirs, HA token stores) **and** in the
|
||||
same path sends it to a destination the package needn't talk to.
|
||||
Reading or sending alone is not enough — the *combination* is the signal.
|
||||
5. **Hides what it does.** Flag opaque data flowing into an execution
|
||||
sink: large encoded / compressed / hex strings (`base64`, `codecs`,
|
||||
`zlib`, `lzma`, `bytes.fromhex`, or any equivalent) passed to `exec` /
|
||||
`eval` / `compile` / `__import__`; identifiers assembled at runtime
|
||||
then imported; or any construct whose evident purpose is to make the
|
||||
behavior unreadable.
|
||||
6. **Hard-coded network destination off-purpose.** Flag outbound URLs or
|
||||
hosts absent from the package's PyPI `project_urls` with no obvious
|
||||
connection to its function — short-link / paste services, ephemeral
|
||||
tunnels, raw IPs, non-default ports against unknown hosts — and any
|
||||
network call at module top-level / `__init__.py` (runs on import for
|
||||
every consumer).
|
||||
|
||||
A clearly out-of-scope behavior that fits none of the above: flag under
|
||||
the closest category and explain. The categories guide reasoning, not bound it.
|
||||
|
||||
**Verdict**
|
||||
|
||||
Aggregate the findings into one of:
|
||||
|
||||
- `☑️ Baseline scan found nothing obvious in <list of inspected files>.
|
||||
This is not a security review — only the cheap checks were run.`
|
||||
Use `☑️` (**not** `✅`) so a passing scan is not read as an endorsement.
|
||||
- `⚠️ <one-line summary>` — patterns with plausible legitimate uses;
|
||||
include path / line / snippet / permalink per match for the reviewer.
|
||||
- `❌ <one-line summary>` — patterns with no legitimate explanation
|
||||
(install-time network execution, decode-and-exec of opaque blobs, reads
|
||||
of `secrets.yaml` / `.storage/auth*`, token exfiltration to an external
|
||||
host); same detail.
|
||||
|
||||
Be precise. False positives are expected — when in doubt prefer `⚠️` with
|
||||
context over `❌`. This check is informational and never blocks the
|
||||
workflow on its own; a human reviewer decides whether to merge.
|
||||
- ✅ — no offending blocking pattern in the surface being reviewed
|
||||
(whole tree for a new package, added lines for a bump). For a bump,
|
||||
phrase the detail as `No new blocking calls introduced in
|
||||
{old_version} → {new_version}.`.
|
||||
- ⚠️ — blocking calls exist only in sync helpers that the async API
|
||||
does not call, or only on a clearly non-hot path (e.g. one-shot
|
||||
setup before the event loop is running). Cite at least one
|
||||
`<file>:<line>` and explain why it is not on the hot path.
|
||||
- ❌ — a blocking call is reachable from an `async def` that is part
|
||||
of the public API on the request / polling path (for a bump: the
|
||||
call was introduced or moved onto the hot path by this version).
|
||||
Cite the offending `<file>:<line>` as a clickable link on the repo
|
||||
host so the contributor can jump to it.
|
||||
|
||||
## Notes
|
||||
|
||||
- Be constructive; reference the inspected file by URL when useful.
|
||||
- Comment dedup is handled by gh-aw's `add_comment` safe-output via
|
||||
the `<!-- requirements-check -->` marker.
|
||||
- If `/tmp/gh-aw/deterministic/results.json` is missing (upstream
|
||||
cancelled/failed), emit nothing — the post-step verification is
|
||||
gated and won't complain.
|
||||
- Be constructive and helpful. Reference the inspected workflow / CI
|
||||
file by URL where useful so the contributor can fix the issue.
|
||||
- The dedup of the requirements-check comment is handled by gh-aw's
|
||||
`add_comment` safe-output via the `<!-- requirements-check -->`
|
||||
marker on the first line of `rendered_comment`.
|
||||
- If the deterministic workflow concluded with a non-success status,
|
||||
this workflow's `if:` guard on `Download deterministic-results
|
||||
artifact` skipped the download. If you find no file at
|
||||
`/tmp/gh-aw/deterministic/results.json`, emit nothing — the post-step
|
||||
verification is also gated and will not complain.
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
Generated
-4
@@ -947,8 +947,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/kiosker/ @Claeysson
|
||||
/homeassistant/components/kitchen_sink/ @home-assistant/core
|
||||
/tests/components/kitchen_sink/ @home-assistant/core
|
||||
/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer
|
||||
/tests/components/klik_aan_klik_uit/ @Phunkafizer
|
||||
/homeassistant/components/kmtronic/ @dgomes
|
||||
/tests/components/kmtronic/ @dgomes
|
||||
/homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh
|
||||
@@ -1086,8 +1084,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/mediaroom/ @dgomes
|
||||
/homeassistant/components/melcloud/ @erwindouna
|
||||
/tests/components/melcloud/ @erwindouna
|
||||
/homeassistant/components/melcloud_home/ @erwindouna
|
||||
/tests/components/melcloud_home/ @erwindouna
|
||||
/homeassistant/components/melissa/ @kennedyshead
|
||||
/tests/components/melissa/ @kennedyshead
|
||||
/homeassistant/components/melnor/ @vanstinator
|
||||
|
||||
@@ -12,18 +12,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
"access_token",
|
||||
"adp_token",
|
||||
"device_private_key",
|
||||
"refresh_token",
|
||||
"store_authentication_cookie",
|
||||
"title",
|
||||
"website_cookies",
|
||||
}
|
||||
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.0.3"]
|
||||
"requirements": ["aioamazondevices==14.0.0"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Coordinator for the Anthropic integration."""
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import anthropic
|
||||
|
||||
@@ -19,12 +20,15 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
|
||||
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
|
||||
|
||||
|
||||
_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"):
|
||||
model_id = model_id[:-9]
|
||||
if model_id.endswith("-4"):
|
||||
if _model_short_form.search(model_id):
|
||||
return model_id + "-0"
|
||||
return model_id
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["anthropic==0.108.0"]
|
||||
"requirements": ["anthropic==0.96.0"]
|
||||
}
|
||||
|
||||
@@ -65,18 +65,18 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
]
|
||||
self._model_list_cache[entry.entry_id] = model_list
|
||||
|
||||
family = (
|
||||
model.removeprefix("claude-")
|
||||
.removesuffix("-preview")
|
||||
.translate(str.maketrans("", "", "0123456789-."))
|
||||
or "haiku"
|
||||
)
|
||||
if "opus" in model:
|
||||
family = "claude-opus"
|
||||
elif "sonnet" in model:
|
||||
family = "claude-sonnet"
|
||||
else:
|
||||
family = "claude-haiku"
|
||||
|
||||
suggested_model = next(
|
||||
(
|
||||
model_option["value"]
|
||||
for model_option in sorted(
|
||||
(m for m in model_list if f"claude-{family}" in m["value"]),
|
||||
(m for m in model_list if family in m["value"]),
|
||||
key=lambda x: x["value"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@@ -8,11 +8,7 @@ from aiohttp import ClientResponseError
|
||||
from pyaqvify import AqvifyAPI, AqvifyAuthException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@@ -53,11 +49,6 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(account_data.account_id)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(), data_updates=user_input
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Aqvify", data=user_input)
|
||||
|
||||
@@ -105,9 +96,3 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: Mapping[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""User initiated reconfiguration."""
|
||||
return await self.async_step_user()
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The entered API key corresponds to a different account."
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -31,7 +31,7 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -144,9 +144,7 @@ def _sensor_device_info_to_hass(
|
||||
adv: Aranet4Advertisement,
|
||||
) -> DeviceInfo:
|
||||
"""Convert a sensor device info to hass device info."""
|
||||
hass_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_BLUETOOTH, adv.device.address)}
|
||||
)
|
||||
hass_device_info = DeviceInfo({})
|
||||
if adv.readings and adv.readings.name:
|
||||
hass_device_info[ATTR_NAME] = adv.readings.name
|
||||
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.7.0"]
|
||||
"requirements": ["hassil==3.6.0"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["mozart-api==6.2.0.44.0"],
|
||||
"requirements": ["mozart-api==5.3.1.108.2"],
|
||||
"zeroconf": ["_bangolufsen._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ BINARY_SENSOR_TYPES = (
|
||||
key="open",
|
||||
device_class=BinarySensorDeviceClass.WINDOW,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="input",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,13 +24,3 @@ OPEN_STATUS: dict[int, str] = {
|
||||
|
||||
LIGHT_MAX_KELVINS = 6500 # 154 Mireds
|
||||
LIGHT_MIN_KELVINS = 2700 # 370 Mireds
|
||||
|
||||
CO2_LEVEL: dict[int, str] = {
|
||||
0: "excellent",
|
||||
1: "good",
|
||||
2: "acceptable",
|
||||
3: "medium",
|
||||
4: "poor",
|
||||
5: "unhealthy",
|
||||
6: "hazardous",
|
||||
}
|
||||
|
||||
@@ -90,10 +90,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
|
||||
if feature.has_tilt:
|
||||
self._attr_supported_features |= (
|
||||
CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
|
||||
CoverEntityFeature.SET_TILT_POSITION
|
||||
| CoverEntityFeature.OPEN_TILT
|
||||
| CoverEntityFeature.CLOSE_TILT
|
||||
)
|
||||
if feature.is_calibrated:
|
||||
self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION
|
||||
|
||||
if feature.tilt_only:
|
||||
self._attr_supported_features &= ~(
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"co2_level": {
|
||||
"default": "mdi:molecule-co2"
|
||||
},
|
||||
"open_status": {
|
||||
"default": "mdi:window-open"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["blebox_uniapi"],
|
||||
"requirements": ["blebox-uniapi==2.5.5"],
|
||||
"requirements": ["blebox-uniapi==2.5.4"],
|
||||
"zeroconf": ["_bbxsrv._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
UnitOfApparentPower,
|
||||
@@ -32,7 +31,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import CO2_LEVEL, OPEN_STATUS
|
||||
from .const import OPEN_STATUS
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
@@ -150,19 +149,6 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
|
||||
options=list(OPEN_STATUS.values()),
|
||||
value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="co2Definition",
|
||||
translation_key="co2_level",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(CO2_LEVEL.values()),
|
||||
value_fn=lambda v: CO2_LEVEL.get(int(v)) if v is not None else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -37,18 +37,6 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"co2_level": {
|
||||
"name": "Carbon dioxide level",
|
||||
"state": {
|
||||
"acceptable": "Acceptable",
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
"hazardous": "Hazardous",
|
||||
"medium": "Medium",
|
||||
"poor": "Poor",
|
||||
"unhealthy": "Unhealthy"
|
||||
}
|
||||
},
|
||||
"open_status": {
|
||||
"state": {
|
||||
"ajar": "Ajar",
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"""The Brands integration."""
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Container, Mapping
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, override
|
||||
|
||||
from aiohttp import ClientError, hdrs, web
|
||||
from aiohttp import ClientError, web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback, valid_domain
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -108,23 +109,18 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
|
||||
class _BrandsBaseView(HomeAssistantView):
|
||||
"""Base view for serving brand images."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the view."""
|
||||
self._hass = hass
|
||||
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
|
||||
|
||||
def _authenticate(self, request: web.Request) -> None:
|
||||
"""Authenticate the request using Bearer token or query token."""
|
||||
access_tokens: deque[str] = self._hass.data[DOMAIN]
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
|
||||
)
|
||||
if not authenticated:
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
raise web.HTTPForbidden
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
return self._hass.data[DOMAIN]
|
||||
|
||||
async def _serve_from_custom_integration(
|
||||
self,
|
||||
@@ -240,8 +236,6 @@ class BrandsIntegrationView(_BrandsBaseView):
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for an integration brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
@@ -274,8 +268,6 @@ class BrandsHardwareView(_BrandsBaseView):
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for a hardware brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not CATEGORY_RE.match(category):
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
# Hardware images have dynamic names like "manufacturer_model.png"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
@@ -12,16 +12,16 @@ import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final, final
|
||||
from typing import Any, Final, final, override
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp import web
|
||||
import attr
|
||||
from propcache.api import cached_property, under_cached_property
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceCandidateInit
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
@@ -776,30 +776,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
class CameraView(HomeAssistantView):
|
||||
"""Base CameraView."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
|
||||
def __init__(self, component: EntityComponent[Camera]) -> None:
|
||||
"""Initialize a basic camera view."""
|
||||
self.component = component
|
||||
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return camera.access_tokens
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
if (camera := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") in camera.access_tokens
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or camera access token
|
||||
raise web.HTTPForbidden
|
||||
|
||||
if not camera.is_on:
|
||||
_LOGGER.debug("Camera is off")
|
||||
raise web.HTTPServiceUnavailable
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
|
||||
"requirements": ["hassil==3.6.0", "home-assistant-intents==2026.6.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sml"],
|
||||
"requirements": ["pysml==0.1.8"]
|
||||
"requirements": ["pysml==0.1.7"]
|
||||
}
|
||||
|
||||
@@ -11,20 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_RADAR_LAYER,
|
||||
CONF_RADAR_LEGEND,
|
||||
CONF_RADAR_OPACITY,
|
||||
CONF_RADAR_RADIUS,
|
||||
CONF_RADAR_TIMESTAMP,
|
||||
CONF_STATION,
|
||||
DEFAULT_RADAR_LAYER,
|
||||
DEFAULT_RADAR_LEGEND,
|
||||
DEFAULT_RADAR_OPACITY,
|
||||
DEFAULT_RADAR_RADIUS,
|
||||
DEFAULT_RADAR_TIMESTAMP,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import CONF_STATION, DOMAIN
|
||||
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -67,15 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
|
||||
errors = errors + 1
|
||||
_LOGGER.warning("Unable to retrieve Environment Canada weather")
|
||||
|
||||
options = config_entry.options
|
||||
radar_data = ECMap(
|
||||
coordinates=(lat, lon),
|
||||
layer=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
|
||||
legend=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
|
||||
timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
|
||||
layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)),
|
||||
radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)),
|
||||
)
|
||||
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
|
||||
radar_coordinator = ECDataUpdateCoordinator(
|
||||
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
|
||||
)
|
||||
|
||||
@@ -9,42 +9,17 @@ from env_canada import ECWeather, ec_exc
|
||||
from env_canada.ec_weather import get_ec_sites_list
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
NumberSelectorMode,
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_RADAR_LAYER,
|
||||
CONF_RADAR_LEGEND,
|
||||
CONF_RADAR_OPACITY,
|
||||
CONF_RADAR_RADIUS,
|
||||
CONF_RADAR_TIMESTAMP,
|
||||
CONF_STATION,
|
||||
CONF_TITLE,
|
||||
DEFAULT_RADAR_LAYER,
|
||||
DEFAULT_RADAR_LEGEND,
|
||||
DEFAULT_RADAR_OPACITY,
|
||||
DEFAULT_RADAR_RADIUS,
|
||||
DEFAULT_RADAR_TIMESTAMP,
|
||||
DOMAIN,
|
||||
RADAR_LAYERS,
|
||||
)
|
||||
from .const import CONF_STATION, CONF_TITLE, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -82,14 +57,6 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
_station_codes: list[dict[str, str]] | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlowHandler:
|
||||
"""Return the options flow handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
async def _get_station_codes(self) -> list[dict[str, str]]:
|
||||
"""Get station codes, cached after first call."""
|
||||
if self._station_codes is None:
|
||||
@@ -160,55 +127,3 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
"""Handle Environment Canada radar camera options."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the radar camera options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
options = self.config_entry.options
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_RADAR_LAYER,
|
||||
default=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=RADAR_LAYERS,
|
||||
translation_key="radar_layer",
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_RADAR_LEGEND,
|
||||
default=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
|
||||
): BooleanSelector(),
|
||||
vol.Required(
|
||||
CONF_RADAR_TIMESTAMP,
|
||||
default=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
|
||||
): BooleanSelector(),
|
||||
vol.Required(
|
||||
CONF_RADAR_OPACITY,
|
||||
default=options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY),
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0, max=100, step=1, mode=NumberSelectorMode.SLIDER
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_RADAR_RADIUS,
|
||||
default=options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS),
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=10, max=2000, step=10, unit_of_measurement="km"
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||
|
||||
@@ -6,19 +6,3 @@ CONF_STATION = "station"
|
||||
CONF_TITLE = "title"
|
||||
DOMAIN = "environment_canada"
|
||||
SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts"
|
||||
|
||||
CONF_RADAR_LAYER = "radar_layer"
|
||||
CONF_RADAR_LEGEND = "radar_legend"
|
||||
CONF_RADAR_TIMESTAMP = "radar_timestamp"
|
||||
CONF_RADAR_OPACITY = "radar_opacity"
|
||||
CONF_RADAR_RADIUS = "radar_radius"
|
||||
|
||||
RADAR_LAYERS = ["rain", "snow", "precip_type"]
|
||||
|
||||
# Defaults preserve the radar behaviour from before the options flow existed:
|
||||
# the precipitation-type layer with the legend hidden.
|
||||
DEFAULT_RADAR_LAYER = "precip_type"
|
||||
DEFAULT_RADAR_LEGEND = False
|
||||
DEFAULT_RADAR_TIMESTAMP = True
|
||||
DEFAULT_RADAR_OPACITY = 65
|
||||
DEFAULT_RADAR_RADIUS = 200
|
||||
|
||||
@@ -117,33 +117,6 @@
|
||||
"message": "Environment Canada is not connected"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"radar_layer": "Radar type",
|
||||
"radar_legend": "Show legend",
|
||||
"radar_opacity": "Radar opacity",
|
||||
"radar_radius": "Map radius",
|
||||
"radar_timestamp": "Show timestamp"
|
||||
},
|
||||
"data_description": {
|
||||
"radar_opacity": "Opacity of the radar layer overlay (0-100)",
|
||||
"radar_radius": "Radius of the radar map in kilometres"
|
||||
},
|
||||
"title": "Radar camera options"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"radar_layer": {
|
||||
"options": {
|
||||
"precip_type": "Precipitation type",
|
||||
"rain": "Rain",
|
||||
"snow": "Snow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_alerts": {
|
||||
"description": "Retrieves the alerts from the selected weather service.",
|
||||
|
||||
@@ -27,7 +27,6 @@ from epson_projector.const import (
|
||||
)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
@@ -63,7 +62,6 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_device_class = MediaPlayerDeviceClass.PROJECTOR
|
||||
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.TURN_ON
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.6"]
|
||||
"requirements": ["home-assistant-frontend==20260527.5"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from gardena_bluetooth.const import (
|
||||
AquaContourBattery,
|
||||
@@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
|
||||
super()._handle_coordinator_update()
|
||||
return
|
||||
|
||||
time = dt_util.utcnow() + timedelta(seconds=value)
|
||||
time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow
|
||||
if not self._attr_native_value:
|
||||
self._attr_native_value = time
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Do you want to set up {name}?\n\nBefore you continue, make sure the device is in pairing mode."
|
||||
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
|
||||
@@ -14,12 +14,7 @@ from homeassistant.helpers.aiohttp_client import (
|
||||
)
|
||||
|
||||
from .const import CONF_REPOSITORIES, CONF_REPOSITORY, DOMAIN, SUBENTRY_TYPE_REPOSITORY
|
||||
from .coordinator import (
|
||||
GithubConfigEntry,
|
||||
GitHubDataUpdateCoordinator,
|
||||
GitHubRuntimeData,
|
||||
GitHubUserDataUpdateCoordinator,
|
||||
)
|
||||
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
@@ -32,14 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
|
||||
client_name=SERVER_SOFTWARE,
|
||||
)
|
||||
|
||||
user_coordinator = GitHubUserDataUpdateCoordinator(
|
||||
hass=hass,
|
||||
config_entry=entry,
|
||||
client=client,
|
||||
)
|
||||
await user_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
repositories: dict[str, GitHubDataUpdateCoordinator] = {}
|
||||
entry.runtime_data = {}
|
||||
for repository_subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_REPOSITORY):
|
||||
repository = repository_subentry.data[CONF_REPOSITORY]
|
||||
coordinator = GitHubDataUpdateCoordinator(
|
||||
@@ -54,12 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
|
||||
if not entry.pref_disable_polling:
|
||||
await coordinator.subscribe()
|
||||
|
||||
repositories[repository_subentry.subentry_id] = coordinator
|
||||
|
||||
entry.runtime_data = GitHubRuntimeData(
|
||||
user_coordinator=user_coordinator,
|
||||
repositories=repositories,
|
||||
)
|
||||
entry.runtime_data[repository_subentry.subentry_id] = coordinator
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_entry))
|
||||
|
||||
@@ -74,7 +57,8 @@ async def async_update_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> N
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
for coordinator in entry.runtime_data.repositories.values():
|
||||
repositories = entry.runtime_data
|
||||
for coordinator in repositories.values():
|
||||
coordinator.unsubscribe()
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Custom data update coordinator for the GitHub integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from aiogithubapi import (
|
||||
GitHubAPI,
|
||||
GitHubAuthenticatedUserModel,
|
||||
GitHubConnectionException,
|
||||
GitHubEventModel,
|
||||
GitHubException,
|
||||
@@ -105,52 +103,7 @@ query ($owner: String!, $repository: String!) {
|
||||
}
|
||||
"""
|
||||
|
||||
type GithubConfigEntry = ConfigEntry[GitHubRuntimeData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitHubRuntimeData:
|
||||
"""Runtime data for the GitHub integration."""
|
||||
|
||||
user_coordinator: GitHubUserDataUpdateCoordinator
|
||||
repositories: dict[str, GitHubDataUpdateCoordinator]
|
||||
|
||||
|
||||
class GitHubUserDataUpdateCoordinator(
|
||||
DataUpdateCoordinator[GitHubAuthenticatedUserModel]
|
||||
):
|
||||
"""Data update coordinator for the authenticated GitHub user."""
|
||||
|
||||
config_entry: GithubConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GithubConfigEntry,
|
||||
client: GitHubAPI,
|
||||
) -> None:
|
||||
"""Initialize GitHub user data update coordinator."""
|
||||
self._client = client
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name="user",
|
||||
update_interval=FALLBACK_UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> GitHubAuthenticatedUserModel:
|
||||
"""Update data."""
|
||||
try:
|
||||
response = await self._client.user.get()
|
||||
except (GitHubConnectionException, GitHubRatelimitException) as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
except GitHubException as exception:
|
||||
LOGGER.exception(exception)
|
||||
raise UpdateFailed(exception) from exception
|
||||
|
||||
return response.data
|
||||
type GithubConfigEntry = ConfigEntry[dict[str, GitHubDataUpdateCoordinator]]
|
||||
|
||||
|
||||
class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
@@ -33,7 +33,7 @@ async def async_get_config_entry_diagnostics(
|
||||
else:
|
||||
data["rate_limit"] = rate_limit_response.data.as_dict
|
||||
|
||||
repositories = config_entry.runtime_data.repositories
|
||||
repositories = config_entry.runtime_data
|
||||
data["repositories"] = {}
|
||||
|
||||
for coordinator in repositories.values():
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
"discussions_count": {
|
||||
"default": "mdi:forum"
|
||||
},
|
||||
"followers": {
|
||||
"default": "mdi:account-multiple"
|
||||
},
|
||||
"following": {
|
||||
"default": "mdi:account-multiple-outline"
|
||||
},
|
||||
"forks_count": {
|
||||
"default": "mdi:source-fork"
|
||||
},
|
||||
@@ -37,12 +31,6 @@
|
||||
"merged_pulls_count": {
|
||||
"default": "mdi:source-merge"
|
||||
},
|
||||
"public_gists": {
|
||||
"default": "mdi:code-json"
|
||||
},
|
||||
"public_repos": {
|
||||
"default": "mdi:source-repository"
|
||||
},
|
||||
"pulls_count": {
|
||||
"default": "mdi:source-pull"
|
||||
},
|
||||
|
||||
@@ -4,8 +4,6 @@ from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from aiogithubapi import GitHubAuthenticatedUserModel
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
@@ -19,11 +17,7 @@ from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
GithubConfigEntry,
|
||||
GitHubDataUpdateCoordinator,
|
||||
GitHubUserDataUpdateCoordinator,
|
||||
)
|
||||
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -147,58 +141,14 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GitHubUserSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes GitHub user sensor entity."""
|
||||
|
||||
value_fn: Callable[[GitHubAuthenticatedUserModel], StateType]
|
||||
|
||||
|
||||
USER_SENSOR_DESCRIPTIONS: tuple[GitHubUserSensorEntityDescription, ...] = (
|
||||
GitHubUserSensorEntityDescription(
|
||||
key="followers",
|
||||
translation_key="followers",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.followers,
|
||||
),
|
||||
GitHubUserSensorEntityDescription(
|
||||
key="following",
|
||||
translation_key="following",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.following,
|
||||
),
|
||||
GitHubUserSensorEntityDescription(
|
||||
key="public_gists",
|
||||
translation_key="public_gists",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.public_gists,
|
||||
),
|
||||
GitHubUserSensorEntityDescription(
|
||||
key="public_repos",
|
||||
translation_key="public_repos",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.public_repos,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GithubConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up GitHub sensor based on a config entry."""
|
||||
user_coordinator = entry.runtime_data.user_coordinator
|
||||
async_add_entities(
|
||||
GitHubUserSensorEntity(user_coordinator, description)
|
||||
for description in USER_SENSOR_DESCRIPTIONS
|
||||
)
|
||||
|
||||
for subentry_id, coordinator in entry.runtime_data.repositories.items():
|
||||
repositories = entry.runtime_data
|
||||
for subentry_id, coordinator in repositories.items():
|
||||
async_add_entities(
|
||||
(
|
||||
GitHubSensorEntity(coordinator, description)
|
||||
@@ -253,37 +203,3 @@ class GitHubSensorEntity(CoordinatorEntity[GitHubDataUpdateCoordinator], SensorE
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
"""Return the extra state attributes."""
|
||||
return self.entity_description.attr_fn(self.coordinator.data)
|
||||
|
||||
|
||||
class GitHubUserSensorEntity(
|
||||
CoordinatorEntity[GitHubUserDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
"""Defines a GitHub user sensor entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
entity_description: GitHubUserSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GitHubUserDataUpdateCoordinator,
|
||||
entity_description: GitHubUserSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{coordinator.data.id}_{entity_description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(coordinator.data.id))},
|
||||
name=coordinator.data.login,
|
||||
manufacturer="GitHub",
|
||||
configuration_url=f"https://github.com/{coordinator.data.login}",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
@@ -36,14 +36,6 @@
|
||||
"name": "Discussions",
|
||||
"unit_of_measurement": "discussions"
|
||||
},
|
||||
"followers": {
|
||||
"name": "Followers",
|
||||
"unit_of_measurement": "followers"
|
||||
},
|
||||
"following": {
|
||||
"name": "Following",
|
||||
"unit_of_measurement": "users"
|
||||
},
|
||||
"forks_count": {
|
||||
"name": "Forks",
|
||||
"unit_of_measurement": "forks"
|
||||
@@ -74,14 +66,6 @@
|
||||
"name": "Merged pull requests",
|
||||
"unit_of_measurement": "pull requests"
|
||||
},
|
||||
"public_gists": {
|
||||
"name": "Public gists",
|
||||
"unit_of_measurement": "gists"
|
||||
},
|
||||
"public_repos": {
|
||||
"name": "Public repositories",
|
||||
"unit_of_measurement": "repositories"
|
||||
},
|
||||
"pulls_count": {
|
||||
"name": "Pull requests",
|
||||
"unit_of_measurement": "pull requests"
|
||||
|
||||
@@ -155,7 +155,7 @@ class GoogleWifiSensor(SensorEntity):
|
||||
class GoogleWifiAPI:
|
||||
"""Get the latest data and update the states."""
|
||||
|
||||
def __init__(self, host, conditions) -> None:
|
||||
def __init__(self, host, conditions):
|
||||
"""Initialize the data object."""
|
||||
uri = "http://"
|
||||
resource = f"{uri}{host}{ENDPOINT}"
|
||||
@@ -182,7 +182,7 @@ class GoogleWifiAPI:
|
||||
self.raw_data = response.json()
|
||||
self.data_format()
|
||||
self.available = True
|
||||
except ValueError, requests.exceptions.RequestException:
|
||||
except ValueError, requests.exceptions.ConnectionError:
|
||||
_LOGGER.warning("Unable to fetch data from Google Wifi")
|
||||
self.available = False
|
||||
self.raw_data = None
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.13.0"]
|
||||
"requirements": ["homematicip==2.12.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["hyponcloud==1.0.1"]
|
||||
"requirements": ["hyponcloud==1.0.0"]
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Diagnostics platform for iAquaLink."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AqualinkConfigEntry
|
||||
|
||||
TO_REDACT = {"serial", "serial_number"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: AqualinkConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
systems = [
|
||||
{
|
||||
"online": coordinator.system.online,
|
||||
"data": {k: v for k, v in coordinator.system.data.items() if k != "name"},
|
||||
"devices": {
|
||||
name: {"class": obj.__class__.__name__, "data": obj.data}
|
||||
for name, obj in (
|
||||
getattr(coordinator.system, "devices", None) or {}
|
||||
).items()
|
||||
},
|
||||
}
|
||||
for coordinator in entry.runtime_data.coordinators.values()
|
||||
]
|
||||
|
||||
return {"systems": async_redact_data(systems, TO_REDACT)}
|
||||
@@ -39,7 +39,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This integration uses a cloud account.
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Container, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
from typing import Final, final
|
||||
from typing import Final, final, override
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp import web
|
||||
import httpx
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import (
|
||||
@@ -314,33 +315,28 @@ class ImageView(HomeAssistantView):
|
||||
"""View to serve an image."""
|
||||
|
||||
name = "api:image:image"
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
url = "/api/image_proxy/{entity_id}"
|
||||
|
||||
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
|
||||
"""Initialize an image view."""
|
||||
self.component = component
|
||||
|
||||
async def _authenticate_request(
|
||||
self, request: web.Request, entity_id: str
|
||||
) -> ImageEntity:
|
||||
"""Authenticate request and return image entity."""
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (image_entity := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return image_entity.access_tokens
|
||||
|
||||
@callback
|
||||
def _get_image_entity(self, entity_id: str) -> ImageEntity:
|
||||
"""Get image entity from request."""
|
||||
if (image_entity := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") in image_entity.access_tokens
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or image entity access token
|
||||
raise web.HTTPForbidden
|
||||
|
||||
return image_entity
|
||||
|
||||
async def head(self, request: web.Request, entity_id: str) -> web.Response:
|
||||
@@ -349,7 +345,7 @@ class ImageView(HomeAssistantView):
|
||||
This is sent by some DLNA renderers, like Samsung ones, prior to sending
|
||||
the GET request.
|
||||
"""
|
||||
image_entity = await self._authenticate_request(request, entity_id)
|
||||
image_entity = self._get_image_entity(entity_id)
|
||||
|
||||
# Don't use `handle` as we don't care about the stream case, we only want
|
||||
# to verify that the image exists.
|
||||
@@ -365,7 +361,7 @@ class ImageView(HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
image_entity = await self._authenticate_request(request, entity_id)
|
||||
image_entity = self._get_image_entity(entity_id)
|
||||
return await self.handle(request, image_entity)
|
||||
|
||||
async def handle(
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Support for Imou camera entities."""
|
||||
|
||||
from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE
|
||||
from pyimouapi.exceptions import ImouException
|
||||
from pyimouapi.ha_device import ImouHaDevice
|
||||
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import PARAM_HEADER_DETECT, imou_device_identifier
|
||||
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
|
||||
from .entity import ImouEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
CAMERA_STREAM_RESOLUTION_SD = "SD"
|
||||
|
||||
# Defaults for pyimouapi ImouHaDeviceManager APIs (async_get_device_stream / async_get_device_image).
|
||||
PYIMOUAPI_LIVE_PROTOCOL = "https"
|
||||
PYIMOUAPI_SNAPSHOT_WAIT_SECONDS = 3
|
||||
|
||||
CAMERA_TYPES = (
|
||||
("camera_sd", CAMERA_STREAM_RESOLUTION_SD),
|
||||
("camera_hd", PARAM_HD),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ImouConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Imou camera entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _add_cameras(new_devices: list[ImouHaDevice]) -> None:
|
||||
device_keys = {imou_device_identifier(device) for device in new_devices}
|
||||
async_add_entities(
|
||||
ImouCamera(coordinator, entity_type, device, resolution)
|
||||
for device in coordinator.devices
|
||||
if device.channel_id is not None
|
||||
if imou_device_identifier(device) in device_keys
|
||||
for entity_type, resolution in CAMERA_TYPES
|
||||
)
|
||||
|
||||
coordinator.new_device_callbacks.append(_add_cameras)
|
||||
|
||||
@callback
|
||||
def _remove_new_device_callback() -> None:
|
||||
if _add_cameras in coordinator.new_device_callbacks:
|
||||
coordinator.new_device_callbacks.remove(_add_cameras)
|
||||
|
||||
entry.async_on_unload(_remove_new_device_callback)
|
||||
_add_cameras(coordinator.devices)
|
||||
|
||||
|
||||
class ImouCamera(ImouEntity, Camera):
|
||||
"""Representation of an Imou camera stream."""
|
||||
|
||||
_attr_supported_features = CameraEntityFeature.STREAM
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ImouDataUpdateCoordinator,
|
||||
entity_type: str,
|
||||
device: ImouHaDevice,
|
||||
resolution: str,
|
||||
) -> None:
|
||||
"""Initialize the camera entity."""
|
||||
self._resolution = resolution
|
||||
Camera.__init__(self)
|
||||
super().__init__(coordinator, entity_type, device)
|
||||
|
||||
async def stream_source(self) -> str | None:
|
||||
"""Return the live stream URL from the Imou cloud."""
|
||||
try:
|
||||
return await self.coordinator.device_manager.async_get_device_stream(
|
||||
self.device,
|
||||
self._resolution,
|
||||
PYIMOUAPI_LIVE_PROTOCOL,
|
||||
)
|
||||
except ImouException as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
async def async_camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return bytes of camera image."""
|
||||
try:
|
||||
return await self.coordinator.device_manager.async_get_device_image(
|
||||
self.device,
|
||||
PYIMOUAPI_SNAPSHOT_WAIT_SECONDS,
|
||||
)
|
||||
except ImouException as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
@property
|
||||
def motion_detection_enabled(self) -> bool:
|
||||
"""Return True when human and/or motion detection switch is on."""
|
||||
header = self.device.switches.get(PARAM_HEADER_DETECT)
|
||||
motion = self.device.switches.get(PARAM_MOTION_DETECT)
|
||||
header_on = bool(header[PARAM_STATE]) if header else False
|
||||
motion_on = bool(motion[PARAM_STATE]) if motion else False
|
||||
return header_on or motion_on
|
||||
@@ -28,7 +28,7 @@ CONF_APP_SECRET = "app_secret"
|
||||
|
||||
PARAM_STATUS = "status"
|
||||
PARAM_STATE = "state"
|
||||
PARAM_HEADER_DETECT = "header_detect"
|
||||
|
||||
|
||||
# How long each PTZ button press moves the camera, in milliseconds (Imou cloud API).
|
||||
PTZ_MOVE_DURATION_MS = 500
|
||||
@@ -36,4 +36,4 @@ PTZ_MOVE_DURATION_MS = 500
|
||||
# Upper bound for a full coordinator refresh (device list + status for all devices).
|
||||
UPDATE_TIMEOUT = 300
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.CAMERA]
|
||||
PLATFORMS = [Platform.BUTTON]
|
||||
|
||||
@@ -41,14 +41,6 @@
|
||||
"ptz_up": {
|
||||
"name": "PTZ up"
|
||||
}
|
||||
},
|
||||
"camera": {
|
||||
"camera_hd": {
|
||||
"name": "Live view HD"
|
||||
},
|
||||
"camera_sd": {
|
||||
"name": "Live view SD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -423,7 +423,7 @@ def get_influx_connection( # noqa: C901
|
||||
if CONF_HOST in conf:
|
||||
kwargs[CONF_HOST] = conf[CONF_HOST]
|
||||
|
||||
if (path := conf.get(CONF_PATH)) is not None and path != "/":
|
||||
if (path := conf.get(CONF_PATH)) is not None:
|
||||
kwargs[CONF_PATH] = path
|
||||
|
||||
if (port := conf.get(CONF_PORT)) is not None:
|
||||
|
||||
@@ -40,8 +40,6 @@ CONF_COMMANDS = "commands"
|
||||
CONF_DATA = "data"
|
||||
CONF_IR_COUNT = "ir_count"
|
||||
|
||||
EMPTY_COMMAND_PLACEHOLDER = '""'
|
||||
|
||||
PLATFORM_SCHEMA = REMOTE_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_MAC): cv.string,
|
||||
@@ -71,35 +69,6 @@ PLATFORM_SCHEMA = REMOTE_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def _format_command_value(value: str) -> str:
|
||||
"""Format a command name or command data value."""
|
||||
value = value.strip()
|
||||
return value or EMPTY_COMMAND_PLACEHOLDER
|
||||
|
||||
|
||||
def _format_command_table(commands: Iterable[dict[str, str]]) -> str:
|
||||
"""Format YAML commands for pyitachip2ir."""
|
||||
return "".join(
|
||||
f"{_format_command_value(command[CONF_NAME])}\n"
|
||||
f"{_format_command_value(command[CONF_DATA])}\n"
|
||||
for command in commands
|
||||
)
|
||||
|
||||
|
||||
def _setup_remote_entity(
|
||||
itachip2ir: Any, device_config: dict[str, Any]
|
||||
) -> ITachIP2IRRemote:
|
||||
"""Create an iTach remote entity from YAML device config."""
|
||||
name = device_config.get(CONF_NAME)
|
||||
modaddr = int(device_config.get(CONF_MODADDR, DEFAULT_MODADDR))
|
||||
connaddr = int(device_config.get(CONF_CONNADDR, DEFAULT_CONNADDR))
|
||||
ir_count = int(device_config.get(CONF_IR_COUNT, DEFAULT_IR_COUNT))
|
||||
command_table = _format_command_table(device_config[CONF_COMMANDS])
|
||||
|
||||
itachip2ir.addDevice(name, modaddr, connaddr, command_table)
|
||||
return ITachIP2IRRemote(itachip2ir, name, ir_count)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -115,17 +84,30 @@ def setup_platform(
|
||||
_LOGGER.error("Unable to find iTach")
|
||||
return
|
||||
|
||||
devices = [
|
||||
_setup_remote_entity(itachip2ir, device_config)
|
||||
for device_config in config[CONF_DEVICES]
|
||||
]
|
||||
devices = []
|
||||
for data in config[CONF_DEVICES]:
|
||||
name = data.get(CONF_NAME)
|
||||
modaddr = int(data.get(CONF_MODADDR, DEFAULT_MODADDR))
|
||||
connaddr = int(data.get(CONF_CONNADDR, DEFAULT_CONNADDR))
|
||||
ir_count = int(data.get(CONF_IR_COUNT, DEFAULT_IR_COUNT))
|
||||
cmddatas = ""
|
||||
for cmd in data.get(CONF_COMMANDS):
|
||||
cmdname = cmd[CONF_NAME].strip()
|
||||
if not cmdname:
|
||||
cmdname = '""'
|
||||
cmddata = cmd[CONF_DATA].strip()
|
||||
if not cmddata:
|
||||
cmddata = '""'
|
||||
cmddatas += f"{cmdname}\n{cmddata}\n"
|
||||
itachip2ir.addDevice(name, modaddr, connaddr, cmddatas)
|
||||
devices.append(ITachIP2IRRemote(itachip2ir, name, ir_count))
|
||||
add_entities(devices, True)
|
||||
|
||||
|
||||
class ITachIP2IRRemote(remote.RemoteEntity):
|
||||
"""Device that sends commands to an ITachIP2IR device."""
|
||||
|
||||
def __init__(self, itachip2ir: Any, name: str | None, ir_count: int) -> None:
|
||||
def __init__(self, itachip2ir, name, ir_count):
|
||||
"""Initialize device."""
|
||||
self.itachip2ir = itachip2ir
|
||||
self._attr_is_on = False
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""The KlikAanKlikUit RC integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_TRANSMITTER
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KlikAanKlikUitRuntimeData:
|
||||
"""Runtime data for the KlikAanKlikUit integration."""
|
||||
|
||||
transmitter_entity_id: str
|
||||
|
||||
|
||||
type KlikAanKlikUitConfigEntry = ConfigEntry[KlikAanKlikUitRuntimeData]
|
||||
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
|
||||
) -> bool:
|
||||
"""Setup KlikAanKlikUit RC from a config entry."""
|
||||
transmitter_entity_id = entry.data[CONF_TRANSMITTER]
|
||||
if hass.states.get(transmitter_entity_id) is None:
|
||||
raise ConfigEntryNotReady(
|
||||
f"RF transmitter entity {transmitter_entity_id} is not available"
|
||||
)
|
||||
|
||||
entry.runtime_data = KlikAanKlikUitRuntimeData(
|
||||
transmitter_entity_id=transmitter_entity_id
|
||||
)
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_listener))
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_listener(
|
||||
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Config flow for the KlikAanKlikUit RC integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols.commands import ModulationType
|
||||
from rf_protocols.commands.kaku import KakuCommand
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.radio_frequency import (
|
||||
async_get_transmitters,
|
||||
async_send_command,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE_ID
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
|
||||
from .const import (
|
||||
CONF_CHANNEL,
|
||||
CONF_GROUP,
|
||||
CONF_TRANSMITTER,
|
||||
DOMAIN,
|
||||
REPEAT_COUNT_LEARN,
|
||||
)
|
||||
|
||||
_SAMPLE_COMMAND = KakuCommand(
|
||||
id=0,
|
||||
channel=1,
|
||||
group=False,
|
||||
on=True,
|
||||
)
|
||||
_CONF_DEVICE_RESPONDED = "device_responded"
|
||||
|
||||
|
||||
class KakuRcConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for KlikAanKlikUit."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize config flow."""
|
||||
self._device_data: dict[str, Any] | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle collecting initial setup data."""
|
||||
try:
|
||||
transmitters = async_get_transmitters(
|
||||
self.hass,
|
||||
_SAMPLE_COMMAND.frequency,
|
||||
ModulationType.OOK,
|
||||
)
|
||||
except HomeAssistantError:
|
||||
return self.async_abort(reason="no_transmitters")
|
||||
|
||||
if not transmitters:
|
||||
return self.async_abort(reason="no_compatible_transmitters")
|
||||
|
||||
if user_input is not None:
|
||||
transmitter: str = user_input[CONF_TRANSMITTER]
|
||||
device_id: int = user_input[CONF_DEVICE_ID]
|
||||
channel: int = user_input[CONF_CHANNEL]
|
||||
group: bool = user_input[CONF_GROUP]
|
||||
|
||||
registry = er.async_get(self.hass)
|
||||
entity_entry = registry.async_get(transmitter)
|
||||
assert entity_entry is not None
|
||||
await self.async_set_unique_id(
|
||||
f"{entity_entry.id}_{device_id}_{channel}_{int(group)}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
self._device_data = {
|
||||
CONF_TRANSMITTER: transmitter,
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_CHANNEL: channel,
|
||||
CONF_GROUP: group,
|
||||
}
|
||||
return await self.async_step_pairing_mode()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self._async_user_schema(transmitters),
|
||||
)
|
||||
|
||||
async def async_step_pairing_mode(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Ask user to put the target device in pairing mode."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="pairing_mode",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
|
||||
assert self._device_data is not None
|
||||
command = KakuCommand(
|
||||
id=self._device_data[CONF_DEVICE_ID],
|
||||
channel=self._device_data[CONF_CHANNEL],
|
||||
group=self._device_data[CONF_GROUP],
|
||||
on=True,
|
||||
frame_repeats=REPEAT_COUNT_LEARN,
|
||||
)
|
||||
await async_send_command(
|
||||
self.hass,
|
||||
self._device_data[CONF_TRANSMITTER],
|
||||
command,
|
||||
)
|
||||
return await self.async_step_pairing_result()
|
||||
|
||||
async def async_step_pairing_result(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm whether the device responded to the learn command."""
|
||||
if user_input is not None:
|
||||
if user_input[_CONF_DEVICE_RESPONDED]:
|
||||
assert self._device_data is not None
|
||||
title = (
|
||||
f"KlikAanKlikUit ID {self._device_data[CONF_DEVICE_ID]} "
|
||||
f"CH {self._device_data[CONF_CHANNEL]}"
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data=self._device_data,
|
||||
)
|
||||
|
||||
return await self.async_step_pairing_mode()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="pairing_result",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
_CONF_DEVICE_RESPONDED,
|
||||
default=False,
|
||||
): selector.BooleanSelector()
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def _async_user_schema(
|
||||
self,
|
||||
transmitters: list[str],
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> vol.Schema:
|
||||
"""Build the one-step add form schema."""
|
||||
if user_input is None:
|
||||
user_input = {}
|
||||
|
||||
suggested_values: dict[str, Any] = {
|
||||
CONF_TRANSMITTER: transmitters[0],
|
||||
CONF_CHANNEL: 1,
|
||||
CONF_GROUP: False,
|
||||
}
|
||||
suggested_values.update(user_input)
|
||||
|
||||
return self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TRANSMITTER): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(include_entities=transmitters),
|
||||
),
|
||||
vol.Required(CONF_DEVICE_ID): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0,
|
||||
max=0x3FFFFFF,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(CONF_CHANNEL): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=16,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(CONF_GROUP): selector.BooleanSelector(),
|
||||
}
|
||||
),
|
||||
suggested_values,
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Constants and helpers for the KlikAanKlikUit (Kaku) integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import CONF_DEVICE_ID as HA_CONF_DEVICE_ID
|
||||
|
||||
DOMAIN: Final = "klik_aan_klik_uit"
|
||||
|
||||
CONF_TRANSMITTER: Final = "transmitter"
|
||||
CONF_DEVICE_ID: Final = HA_CONF_DEVICE_ID
|
||||
CONF_CHANNEL: Final = "channel"
|
||||
CONF_GROUP: Final = "group"
|
||||
REPEAT_COUNT_LEARN: Final = 10 # Higher repeats for learning/pairing
|
||||
|
||||
|
||||
def format_device_summary(device_id: int, channel: int, group: bool) -> str:
|
||||
"""Return a concise summary string for the configured device."""
|
||||
group_text = "on" if group else "off"
|
||||
return f"ID {device_id} CH {channel} Group {group_text}"
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "klik_aan_klik_uit",
|
||||
"name": "KlikAanKlikUit",
|
||||
"codeowners": ["@Phunkafizer"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["radio_frequency"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/klik_aan_klik_uit",
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze"
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide service actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: This integration uses local RF commands and has no account auth.
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: This integration does not use outbound web requests.
|
||||
strict-typing: todo
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_compatible_transmitters": "No compatible radio frequency transmitter is available for this integration.",
|
||||
"no_transmitters": "[%key:common::config_flow::abort::no_radio_frequency_transmitters%]"
|
||||
},
|
||||
"error": {},
|
||||
"step": {
|
||||
"pairing_mode": {
|
||||
"description": "Bring device into learn mode by pushing it's button for more than 2 seconds, then press Ok.",
|
||||
"title": "Pair device"
|
||||
},
|
||||
"pairing_result": {
|
||||
"data": {
|
||||
"device_responded": "Did the device respond?"
|
||||
},
|
||||
"data_description": {
|
||||
"device_responded": "Select Yes if the target device reacted to the learn command."
|
||||
},
|
||||
"description": "Select Yes to continue setup. Select No to return to learn mode and resend the learn command.",
|
||||
"title": "Confirm pairing"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"channel": "Channel",
|
||||
"device_id": "Device ID",
|
||||
"group": "Group",
|
||||
"transmitter": "[%key:common::config_flow::data::radio_frequency_transmitter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "The channel of the target KlikAanKlikUit device (1-16).",
|
||||
"device_id": "The unique KlikAanKlikUit device ID.",
|
||||
"group": "Whether to send commands to the group address instead of a single device.",
|
||||
"transmitter": "[%key:common::config_flow::data_description::radio_frequency_transmitter%]"
|
||||
},
|
||||
"description": "Choose the transmitter and configure your device settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Switch platform for KlikAanKlikUit RC on/off control."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols.commands.kaku import KakuCommand
|
||||
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.const import CONF_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from . import KlikAanKlikUitConfigEntry
|
||||
from .const import CONF_CHANNEL, CONF_GROUP, DOMAIN, format_device_summary
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: KlikAanKlikUitConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the KlikAanKlikUit switch entity."""
|
||||
async_add_entities([KlikAanKlikUitSwitch(config_entry)])
|
||||
|
||||
|
||||
class KlikAanKlikUitSwitch(SwitchEntity, RestoreEntity):
|
||||
"""Switch entity for KlikAanKlikUit devices."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Output"
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, entry: KlikAanKlikUitConfigEntry) -> None:
|
||||
"""Initialize the switch."""
|
||||
self._transmitter = entry.runtime_data.transmitter_entity_id
|
||||
self._device_id: int = entry.data[CONF_DEVICE_ID]
|
||||
self._channel: int = entry.data[CONF_CHANNEL]
|
||||
self._group: bool = entry.data[CONF_GROUP]
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="KlikAanKlikUit",
|
||||
model="KlikAanKlikUit RC device",
|
||||
sw_version=format_device_summary(
|
||||
self._device_id, self._channel, self._group
|
||||
),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to transmitter state and restore last switch state."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
transmitter_entity_id = er.async_validate_entity_id(
|
||||
er.async_get(self.hass), self._transmitter
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_transmitter_state_changed(
|
||||
event: Event[EventStateChangedData],
|
||||
) -> None:
|
||||
new_state = event.data["new_state"]
|
||||
available = new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
if available != self._attr_available:
|
||||
self._attr_available = available
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[transmitter_entity_id],
|
||||
_async_transmitter_state_changed,
|
||||
)
|
||||
)
|
||||
|
||||
transmitter_state = self.hass.states.get(transmitter_entity_id)
|
||||
self._attr_available = (
|
||||
transmitter_state is not None
|
||||
and transmitter_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
if (last_state := await self.async_get_last_state()) is not None:
|
||||
self._attr_is_on = last_state.state == STATE_ON
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self._async_send(True)
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self._async_send(False)
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_send(self, on: bool) -> None:
|
||||
"""Send on/off command."""
|
||||
command = KakuCommand(
|
||||
id=self._device_id,
|
||||
group=self._group,
|
||||
channel=self._channel,
|
||||
on=on,
|
||||
)
|
||||
await async_send_command(
|
||||
self.hass, self._transmitter, command, context=self._context
|
||||
)
|
||||
@@ -422,7 +422,9 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity):
|
||||
},
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
"Invalid alarm code provided",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_code",
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Container, Mapping
|
||||
from contextlib import suppress
|
||||
import datetime as dt
|
||||
from enum import StrEnum
|
||||
@@ -12,7 +12,7 @@ import hashlib
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any, Final, Required, TypedDict, final
|
||||
from typing import Any, Final, Required, TypedDict, final, override
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiohttp
|
||||
@@ -24,7 +24,7 @@ import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ( # noqa: F401
|
||||
@@ -50,7 +50,7 @@ from homeassistant.const import ( # noqa: F401
|
||||
STATE_PLAYING,
|
||||
STATE_STANDBY,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
@@ -1249,7 +1249,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
class MediaPlayerImageView(HomeAssistantView):
|
||||
"""Media player view to serve an image."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
url = "/api/media_player_proxy/{entity_id}"
|
||||
name = "api:media_player:image"
|
||||
extra_urls = [
|
||||
@@ -1262,6 +1262,15 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
"""Initialize a media player view."""
|
||||
self.component = component
|
||||
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (player := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return (player.access_token,)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
request: web.Request,
|
||||
@@ -1271,21 +1280,9 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
) -> web.Response:
|
||||
"""Start a get request."""
|
||||
if (player := self.component.get_entity(entity_id)) is None:
|
||||
status = (
|
||||
HTTPStatus.NOT_FOUND
|
||||
if request[KEY_AUTHENTICATED]
|
||||
else HTTPStatus.UNAUTHORIZED
|
||||
)
|
||||
return web.Response(status=status)
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
assert isinstance(player, MediaPlayerEntity)
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") == player.access_token
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
return web.Response(status=HTTPStatus.UNAUTHORIZED)
|
||||
|
||||
if media_content_type and media_content_id:
|
||||
media_image_id = request.query.get("media_image_id")
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""The MELCloud Home integration."""
|
||||
|
||||
from aiomelcloudhome import MELCloudHome, MelCloudHomeAuth
|
||||
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: MelCloudHomeConfigEntry
|
||||
) -> bool:
|
||||
"""Set up MELCloud Home from a config entry."""
|
||||
session = async_get_clientsession(hass)
|
||||
auth = MelCloudHomeAuth(
|
||||
username=entry.data[CONF_EMAIL],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
client = MELCloudHome(auth=auth, session=session)
|
||||
|
||||
coordinator = MelCloudHomeCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: MelCloudHomeConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Binary sensor platform for MELCloud Home."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiomelcloudhome import ATAUnit, ATWUnit
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
|
||||
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ATABinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Class to hold MELCloud Home ATA binary sensor description."""
|
||||
|
||||
state_fn: Callable[[ATAUnit], bool | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ATWBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Class to hold MELCloud Home ATW binary sensor description."""
|
||||
|
||||
state_fn: Callable[[ATWUnit], bool | None]
|
||||
|
||||
|
||||
ATA_SENSORS: tuple[ATABinarySensorEntityDescription, ...] = (
|
||||
ATABinarySensorEntityDescription(
|
||||
key="error",
|
||||
translation_key="error",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
state_fn=lambda unit: unit.is_in_error,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ATABinarySensorEntityDescription(
|
||||
key="standby",
|
||||
translation_key="standby",
|
||||
state_fn=lambda unit: unit.in_standby_mode,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
ATW_SENSORS: tuple[ATWBinarySensorEntityDescription, ...] = (
|
||||
ATWBinarySensorEntityDescription(
|
||||
key="error",
|
||||
translation_key="error",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
state_fn=lambda unit: unit.is_in_error,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ATWBinarySensorEntityDescription(
|
||||
key="standby",
|
||||
translation_key="standby",
|
||||
state_fn=lambda unit: unit.in_standby_mode,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ATWBinarySensorEntityDescription(
|
||||
key="forced_hot_water",
|
||||
translation_key="forced_hot_water",
|
||||
state_fn=lambda unit: unit.forced_hot_water_mode,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MelCloudHomeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up MELCloud Home binary sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
|
||||
async_add_entities(
|
||||
ATABinarySensor(coordinator, entity_description, unit)
|
||||
for entity_description in ATA_SENSORS
|
||||
for unit in units
|
||||
)
|
||||
|
||||
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
|
||||
async_add_entities(
|
||||
ATWBinarySensor(coordinator, entity_description, unit)
|
||||
for entity_description in ATW_SENSORS
|
||||
for unit in units
|
||||
)
|
||||
|
||||
coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
|
||||
coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
|
||||
|
||||
_async_add_new_ata_units(list(coordinator.ata_units.values()))
|
||||
_async_add_new_atw_units(list(coordinator.atw_units.values()))
|
||||
|
||||
|
||||
class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity):
|
||||
"""Representation of a MELCloud Home ATA binary sensor."""
|
||||
|
||||
entity_description: ATABinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelCloudHomeCoordinator,
|
||||
entity_description: ATABinarySensorEntityDescription,
|
||||
unit: ATAUnit,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.state_fn(self.unit)
|
||||
|
||||
|
||||
class ATWBinarySensor(MelCloudHomeATWUnitEntity, BinarySensorEntity):
|
||||
"""Representation of a MELCloud Home ATW binary sensor."""
|
||||
|
||||
entity_description: ATWBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelCloudHomeCoordinator,
|
||||
entity_description: ATWBinarySensorEntityDescription,
|
||||
unit: ATWUnit,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{unit.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.state_fn(self.unit)
|
||||
@@ -1,371 +0,0 @@
|
||||
"""Climate platform for MELCloud Home."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiomelcloudhome import (
|
||||
ATAFanSpeed,
|
||||
ATAOperationMode,
|
||||
ATAUnit,
|
||||
ATAVaneHorizontal,
|
||||
ATAVaneVertical,
|
||||
ATWUnit,
|
||||
ATWZoneMode,
|
||||
)
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
|
||||
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWZoneEntity
|
||||
|
||||
ATA_HVAC_MODE_TO_OPERATION: dict[HVACMode, ATAOperationMode] = {
|
||||
HVACMode.HEAT: ATAOperationMode.HEAT,
|
||||
HVACMode.COOL: ATAOperationMode.COOL,
|
||||
HVACMode.AUTO: ATAOperationMode.AUTOMATIC,
|
||||
HVACMode.DRY: ATAOperationMode.DRY,
|
||||
HVACMode.FAN_ONLY: ATAOperationMode.FAN,
|
||||
}
|
||||
|
||||
ATA_OPERATION_TO_HVAC_MODE: dict[ATAOperationMode, HVACMode] = {
|
||||
value: key for key, value in ATA_HVAC_MODE_TO_OPERATION.items()
|
||||
}
|
||||
|
||||
ATA_FAN_SPEED_TO_HA: dict[ATAFanSpeed, str] = {
|
||||
ATAFanSpeed.AUTO: "auto",
|
||||
ATAFanSpeed.ONE: "speed_1",
|
||||
ATAFanSpeed.TWO: "speed_2",
|
||||
ATAFanSpeed.THREE: "speed_3",
|
||||
ATAFanSpeed.FOUR: "speed_4",
|
||||
ATAFanSpeed.FIVE: "speed_5",
|
||||
}
|
||||
|
||||
HA_FAN_SPEED_TO_ATA: dict[str, ATAFanSpeed] = {
|
||||
value: key for key, value in ATA_FAN_SPEED_TO_HA.items()
|
||||
}
|
||||
|
||||
ATA_VANE_VERTICAL_TO_HA: dict[ATAVaneVertical, str] = {
|
||||
ATAVaneVertical.AUTO: "auto",
|
||||
ATAVaneVertical.SWING: "swing",
|
||||
ATAVaneVertical.ONE: "position_1",
|
||||
ATAVaneVertical.TWO: "position_2",
|
||||
ATAVaneVertical.THREE: "position_3",
|
||||
ATAVaneVertical.FOUR: "position_4",
|
||||
ATAVaneVertical.FIVE: "position_5",
|
||||
}
|
||||
|
||||
HA_VANE_VERTICAL_TO_ATA: dict[str, ATAVaneVertical] = {
|
||||
value: key for key, value in ATA_VANE_VERTICAL_TO_HA.items()
|
||||
}
|
||||
|
||||
ATA_VANE_HORIZONTAL_TO_HA: dict[ATAVaneHorizontal, str] = {
|
||||
ATAVaneHorizontal.AUTO: "auto",
|
||||
ATAVaneHorizontal.SWING: "swing",
|
||||
ATAVaneHorizontal.LEFT: "left",
|
||||
ATAVaneHorizontal.LEFT_CENTRE: "left_centre",
|
||||
ATAVaneHorizontal.CENTRE: "centre",
|
||||
ATAVaneHorizontal.RIGHT_CENTRE: "right_centre",
|
||||
ATAVaneHorizontal.RIGHT: "right",
|
||||
}
|
||||
|
||||
HA_VANE_HORIZONTAL_TO_ATA: dict[str, ATAVaneHorizontal] = {
|
||||
value: key for key, value in ATA_VANE_HORIZONTAL_TO_HA.items()
|
||||
}
|
||||
|
||||
ATW_ZONE_MODE_TO_HVAC_MODE: dict[ATWZoneMode, HVACMode] = {
|
||||
ATWZoneMode.HEAT_ROOM_TEMPERATURE: HVACMode.HEAT,
|
||||
ATWZoneMode.HEAT_FLOW_TEMPERATURE: HVACMode.HEAT,
|
||||
ATWZoneMode.HEAT_CURVE: HVACMode.HEAT,
|
||||
ATWZoneMode.COOL_ROOM_TEMPERATURE: HVACMode.COOL,
|
||||
ATWZoneMode.COOL_FLOW_TEMPERATURE: HVACMode.COOL,
|
||||
}
|
||||
|
||||
HVAC_MODE_TO_ATW_ZONE_MODE: dict[HVACMode, ATWZoneMode] = {
|
||||
HVACMode.HEAT: ATWZoneMode.HEAT_ROOM_TEMPERATURE,
|
||||
HVACMode.COOL: ATWZoneMode.COOL_ROOM_TEMPERATURE,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MelCloudHomeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up MELCloud Home climate entities from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
|
||||
async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units)
|
||||
|
||||
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
|
||||
async_add_entities(
|
||||
ATWZoneClimateEntity(coordinator, unit, zone_number)
|
||||
for unit in units
|
||||
for zone_number in (
|
||||
[1, 2]
|
||||
if (unit.capabilities and unit.capabilities.has_zone2)
|
||||
or (unit.capabilities is None and unit.has_zone2)
|
||||
else [1]
|
||||
)
|
||||
)
|
||||
|
||||
coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
|
||||
coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
|
||||
|
||||
_async_add_new_ata_units(list(coordinator.ata_units.values()))
|
||||
_async_add_new_atw_units(list(coordinator.atw_units.values()))
|
||||
|
||||
|
||||
class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity):
|
||||
"""Climate entity for a MELCloud Home Air-to-Air unit."""
|
||||
|
||||
_attr_translation_key = "ata_unit"
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_swing_modes = list(ATA_VANE_VERTICAL_TO_HA.values())
|
||||
_attr_swing_horizontal_modes = list(ATA_VANE_HORIZONTAL_TO_HA.values())
|
||||
|
||||
def __init__(self, coordinator: MelCloudHomeCoordinator, unit: ATAUnit) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
if unit.settings is not None:
|
||||
if unit.settings.get("VaneVerticalDirection") is not None:
|
||||
features |= ClimateEntityFeature.SWING_MODE
|
||||
if unit.settings.get("VaneHorizontalDirection") is not None:
|
||||
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
|
||||
self._attr_supported_features = features
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return HVAC modes supported by this unit based on its capabilities."""
|
||||
if self.unit.capabilities is None:
|
||||
return [
|
||||
HVACMode.OFF,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.COOL,
|
||||
HVACMode.AUTO,
|
||||
HVACMode.DRY,
|
||||
HVACMode.FAN_ONLY,
|
||||
]
|
||||
|
||||
modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
if self.unit.capabilities.has_cool_operation_mode is not False:
|
||||
modes.append(HVACMode.COOL)
|
||||
if self.unit.capabilities.has_auto_operation_mode is not False:
|
||||
modes.append(HVACMode.AUTO)
|
||||
if self.unit.capabilities.has_dry_operation_mode is not False:
|
||||
modes.append(HVACMode.DRY)
|
||||
if self.unit.capabilities.has_fan_operation_mode is not False:
|
||||
modes.append(HVACMode.FAN_ONLY)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
"""Return fan modes supported by this unit based on its capabilities."""
|
||||
capabilities = self.unit.capabilities
|
||||
number = (
|
||||
capabilities.number_of_fan_speeds
|
||||
if capabilities is not None
|
||||
and capabilities.number_of_fan_speeds is not None
|
||||
else len(ATA_FAN_SPEED_TO_HA) - 1
|
||||
)
|
||||
all_speeds = list(ATA_FAN_SPEED_TO_HA.values())
|
||||
return [all_speeds[0], *all_speeds[1 : number + 1]]
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current room temperature."""
|
||||
return self.unit.room_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
return self.unit.set_temperature
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return the current HVAC mode."""
|
||||
return (
|
||||
ATA_OPERATION_TO_HVAC_MODE.get(self.unit.operation_mode, HVACMode.OFF)
|
||||
if self.unit.power and self.unit.operation_mode
|
||||
else HVACMode.OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
return (
|
||||
ATA_FAN_SPEED_TO_HA.get(self.unit.set_fan_speed)
|
||||
if self.unit.set_fan_speed is not None
|
||||
else None
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC mode."""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.client.control_ata_unit(self._unit_id, power=False)
|
||||
else:
|
||||
await self.coordinator.client.control_ata_unit(
|
||||
self._unit_id,
|
||||
power=True,
|
||||
operation_mode=ATA_HVAC_MODE_TO_OPERATION[hvac_mode],
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the target temperature."""
|
||||
await self.coordinator.client.control_ata_unit(
|
||||
self._unit_id, set_temperature=kwargs[ATTR_TEMPERATURE]
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str:
|
||||
"""Return the current vertical vane direction."""
|
||||
return ATA_VANE_VERTICAL_TO_HA[self.unit.settings["VaneVerticalDirection"]]
|
||||
|
||||
@property
|
||||
def swing_horizontal_mode(self) -> str:
|
||||
"""Return the current horizontal vane direction."""
|
||||
return ATA_VANE_HORIZONTAL_TO_HA[self.unit.settings["VaneHorizontalDirection"]]
|
||||
|
||||
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
|
||||
"""Set the horizontal vane direction."""
|
||||
await self.coordinator.client.control_ata_unit(
|
||||
self._unit_id,
|
||||
vane_horizontal_direction=HA_VANE_HORIZONTAL_TO_ATA[swing_horizontal_mode],
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set the vertical vane direction."""
|
||||
await self.coordinator.client.control_ata_unit(
|
||||
self._unit_id, vane_vertical_direction=HA_VANE_VERTICAL_TO_ATA[swing_mode]
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set the fan mode."""
|
||||
await self.coordinator.client.control_ata_unit(
|
||||
self._unit_id, set_fan_speed=HA_FAN_SPEED_TO_ATA[fan_mode]
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the unit on."""
|
||||
await self.coordinator.client.control_ata_unit(self._unit_id, power=True)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the unit off."""
|
||||
await self.coordinator.client.control_ata_unit(self._unit_id, power=False)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class ATWZoneClimateEntity(MelCloudHomeATWZoneEntity, ClimateEntity):
|
||||
"""Climate entity for a MELCloud Home ATW zone."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return HVAC modes supported by this zone based on unit capabilities."""
|
||||
modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
if (
|
||||
self.unit.capabilities is None
|
||||
or self.unit.capabilities.has_cooling_mode is not False
|
||||
):
|
||||
modes.append(HVACMode.COOL)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def _zone_mode(self) -> ATWZoneMode | None:
|
||||
"""Return the current ATW zone mode."""
|
||||
if self.zone_number == 1:
|
||||
return self.unit.operation_mode_zone1
|
||||
return self.unit.operation_mode_zone2
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current zone temperature."""
|
||||
return (
|
||||
self.unit.room_temperature_zone1
|
||||
if self.zone_number == 1
|
||||
else self.unit.room_temperature_zone2
|
||||
)
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target zone temperature."""
|
||||
return (
|
||||
self.unit.set_temperature_zone1
|
||||
if self.zone_number == 1
|
||||
else self.unit.set_temperature_zone2
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return the current HVAC mode."""
|
||||
return (
|
||||
ATW_ZONE_MODE_TO_HVAC_MODE.get(self._zone_mode, HVACMode.OFF)
|
||||
if self.unit.power and self._zone_mode
|
||||
else HVACMode.OFF
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC mode."""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.client.control_atw_unit(self._unit_id, power=False)
|
||||
else:
|
||||
zone_mode = HVAC_MODE_TO_ATW_ZONE_MODE[hvac_mode]
|
||||
if self.zone_number == 1:
|
||||
await self.coordinator.client.control_atw_unit(
|
||||
self._unit_id,
|
||||
power=True,
|
||||
operation_mode_zone1=zone_mode,
|
||||
)
|
||||
else:
|
||||
await self.coordinator.client.control_atw_unit(
|
||||
self._unit_id,
|
||||
power=True,
|
||||
operation_mode_zone2=zone_mode,
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the target temperature."""
|
||||
temperature = kwargs[ATTR_TEMPERATURE]
|
||||
if self.zone_number == 1:
|
||||
await self.coordinator.client.control_atw_unit(
|
||||
self._unit_id, set_temperature_zone1=temperature
|
||||
)
|
||||
else:
|
||||
await self.coordinator.client.control_atw_unit(
|
||||
self._unit_id, set_temperature_zone2=temperature
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the zone on."""
|
||||
await self.coordinator.client.control_atw_unit(self._unit_id, power=True)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the zone off."""
|
||||
await self.coordinator.client.control_atw_unit(self._unit_id, power=False)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Config flow for MELCloud Home."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiomelcloudhome import MELCloudHome, MelCloudHomeAuth
|
||||
from aiomelcloudhome.exceptions import (
|
||||
MelCloudHomeAuthenticationError,
|
||||
MelCloudHomeConnectionError,
|
||||
MelCloudHomeTimeoutError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username")
|
||||
),
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MelCloudHomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for MELCloud Home."""
|
||||
|
||||
async def _async_validate_credentials(
|
||||
self, email: str, password: str
|
||||
) -> tuple[dict[str, str], str | None]:
|
||||
"""Validate credentials against MELCloud Home API."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
auth = MelCloudHomeAuth(username=email, password=password, session=session)
|
||||
client = MELCloudHome(auth=auth, session=session)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
user_id: str | None = None
|
||||
|
||||
try:
|
||||
context = await client.get_context()
|
||||
except MelCloudHomeAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except MelCloudHomeConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except MelCloudHomeTimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected error while validating MELCloud Home credentials"
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
user_id = context.id
|
||||
|
||||
return errors, user_id
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
errors, user_id = await self._async_validate_credentials(
|
||||
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
|
||||
)
|
||||
if not errors:
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL],
|
||||
data={
|
||||
CONF_EMAIL: user_input[CONF_EMAIL],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Constants for the MELCloud Home integration."""
|
||||
|
||||
DOMAIN = "melcloud_home"
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Coordinator for MELCloud Home."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome, UserContext
|
||||
from aiomelcloudhome.exceptions import (
|
||||
MelCloudHomeAuthenticationError,
|
||||
MelCloudHomeConnectionError,
|
||||
MelCloudHomeTimeoutError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
type MelCloudHomeConfigEntry = ConfigEntry[MelCloudHomeCoordinator]
|
||||
|
||||
|
||||
class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]):
|
||||
"""Coordinator to manage fetching MELCloud Home data."""
|
||||
|
||||
config_entry: MelCloudHomeConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: MelCloudHomeConfigEntry,
|
||||
client: MELCloudHome,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
self.ata_units: dict[str, ATAUnit] = {}
|
||||
self.atw_units: dict[str, ATWUnit] = {}
|
||||
self.known_ata: set[str] = set()
|
||||
self.known_atw: set[str] = set()
|
||||
self.new_ata_callbacks: list[Callable[[list[ATAUnit]], None]] = []
|
||||
self.new_atw_callbacks: list[Callable[[list[ATWUnit]], None]] = []
|
||||
|
||||
def _notify_new_units(self, data: UserContext) -> None:
|
||||
"""Notify callbacks when new units are discovered."""
|
||||
current_ata = [
|
||||
unit for building in data.buildings for unit in building.air_to_air_units
|
||||
]
|
||||
self.ata_units = {unit.id: unit for unit in current_ata}
|
||||
current_ata_ids = {unit.id for unit in current_ata}
|
||||
self.known_ata &= current_ata_ids
|
||||
new_ata_ids = current_ata_ids - self.known_ata
|
||||
new_ata_units = [unit for unit in current_ata if unit.id in new_ata_ids]
|
||||
if new_ata_units:
|
||||
_LOGGER.debug("Discovered new ATA units: %s", new_ata_units)
|
||||
self.known_ata.update(unit.id for unit in new_ata_units)
|
||||
for ata_callback in self.new_ata_callbacks:
|
||||
ata_callback(new_ata_units)
|
||||
|
||||
current_atw_units = [
|
||||
unit for building in data.buildings for unit in building.air_to_water_units
|
||||
]
|
||||
self.atw_units = {unit.id: unit for unit in current_atw_units}
|
||||
current_atw_ids = {unit.id for unit in current_atw_units}
|
||||
self.known_atw &= current_atw_ids
|
||||
new_atw_ids = current_atw_ids - self.known_atw
|
||||
new_atw_units = [unit for unit in current_atw_units if unit.id in new_atw_ids]
|
||||
if new_atw_units:
|
||||
_LOGGER.debug("Discovered new ATW units: %s", new_atw_units)
|
||||
self.known_atw.update(unit.id for unit in new_atw_units)
|
||||
for atw_callback in self.new_atw_callbacks:
|
||||
atw_callback(new_atw_units)
|
||||
|
||||
async def _async_update_data(self) -> UserContext:
|
||||
"""Fetch data from the MELCloud Home API."""
|
||||
try:
|
||||
data = await self.client.get_context()
|
||||
except MelCloudHomeAuthenticationError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except MelCloudHomeConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except MelCloudHomeTimeoutError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
else:
|
||||
return data
|
||||
|
||||
@callback
|
||||
def _async_refresh_finished(self) -> None:
|
||||
"""Notify entity callbacks after coordinator data has been updated."""
|
||||
if self.data is not None:
|
||||
self._notify_new_units(self.data)
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Base entities for MELCloud Home."""
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from aiomelcloudhome import ATAUnit, ATWUnit
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MelCloudHomeCoordinator
|
||||
|
||||
|
||||
class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]):
|
||||
"""Base entity for MELCloud Home."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
|
||||
class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity):
|
||||
"""Base entity for a MELCloud Home unit."""
|
||||
|
||||
def __init__(self, coordinator: MelCloudHomeCoordinator, unit: _UnitT) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._unit_id = unit.id
|
||||
self._attr_unique_id = unit.id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unit.id)},
|
||||
name=unit.name,
|
||||
manufacturer="Mitsubishi Electric",
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _units_dict(self) -> dict[str, _UnitT]:
|
||||
"""Return the coordinator's units dict keyed by id."""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the entity is available."""
|
||||
return super().available and self._unit_id in self._units_dict()
|
||||
|
||||
@property
|
||||
def unit(self) -> _UnitT:
|
||||
"""Return the current unit state from coordinator data."""
|
||||
return self._units_dict()[self._unit_id]
|
||||
|
||||
|
||||
class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]):
|
||||
"""Base entity for a MELCloud Home Air-to-Air unit."""
|
||||
|
||||
def _units_dict(self) -> dict[str, ATAUnit]:
|
||||
"""Return ATA units dict from coordinator."""
|
||||
return self.coordinator.ata_units
|
||||
|
||||
|
||||
class MelCloudHomeATWUnitEntity(MelCloudHomeUnitEntity[ATWUnit]):
|
||||
"""Base entity for a MELCloud Home Air-to-Water unit."""
|
||||
|
||||
def _units_dict(self) -> dict[str, ATWUnit]:
|
||||
"""Return ATW units dict from coordinator."""
|
||||
return self.coordinator.atw_units
|
||||
|
||||
|
||||
class MelCloudHomeATWZoneEntity(MelCloudHomeATWUnitEntity):
|
||||
"""Base entity for an ATW zone entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelCloudHomeCoordinator,
|
||||
unit: ATWUnit,
|
||||
zone_number: int,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, unit)
|
||||
self._zone_number = zone_number
|
||||
self._attr_unique_id = f"{unit.id}_zone_{zone_number}"
|
||||
self._attr_name = f"Zone {zone_number}"
|
||||
|
||||
@property
|
||||
def zone_number(self) -> int:
|
||||
"""Return the zone number."""
|
||||
return self._zone_number
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"forced_hot_water": {
|
||||
"default": "mdi:water-boiler"
|
||||
},
|
||||
"standby": {
|
||||
"default": "mdi:power-sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "melcloud_home",
|
||||
"name": "MELCloud Home",
|
||||
"codeowners": ["@erwindouna"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/melcloud_home",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiomelcloudhome"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiomelcloudhome==0.1.5"]
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No custom actions defined.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No custom actions defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Coordinator handles polling.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: No custom actions defined.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"timeout_connect": "Timeout while communicating with MELCloud Home API",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Email address for your MELCloud Home account.",
|
||||
"password": "Password for your MELCloud Home account."
|
||||
},
|
||||
"description": "Login to MELCloud Home with the email address and password associated with your account."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"error": {
|
||||
"name": "Error"
|
||||
},
|
||||
"forced_hot_water": {
|
||||
"name": "Forced hot water"
|
||||
},
|
||||
"standby": {
|
||||
"name": "Standby"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"ata_unit": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"speed_1": "Speed 1",
|
||||
"speed_2": "Speed 2",
|
||||
"speed_3": "Speed 3",
|
||||
"speed_4": "Speed 4",
|
||||
"speed_5": "Speed 5"
|
||||
}
|
||||
},
|
||||
"swing_horizontal_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"centre": "Centre",
|
||||
"left": "Left",
|
||||
"left_centre": "Left centre",
|
||||
"right": "Right",
|
||||
"right_centre": "Right centre",
|
||||
"swing": "Swing"
|
||||
}
|
||||
},
|
||||
"swing_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"position_1": "Position 1",
|
||||
"position_2": "Position 2",
|
||||
"position_3": "Position 3",
|
||||
"position_4": "Position 4",
|
||||
"position_5": "Position 5",
|
||||
"swing": "Swing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Error communicating with MELCloud Home API: {error}"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "An error occurred while trying to authenticate: {error}"
|
||||
},
|
||||
"timeout_connect": {
|
||||
"message": "Timeout while communicating with MELCloud Home API: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,23 +42,12 @@ class MitsubishiComfortCoordinator(DataUpdateCoordinator[IndoorUnit | KumoStatio
|
||||
try:
|
||||
success = await self.device.update_status()
|
||||
except Exception as err:
|
||||
# The user-facing UpdateFailed message is translated and omits the IP;
|
||||
# log it here so the failing address is visible in debug logs.
|
||||
_LOGGER.debug(
|
||||
"Error polling %s at %s: %s",
|
||||
self.device.name,
|
||||
self.device.address,
|
||||
err,
|
||||
)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"device_name": self.device.name},
|
||||
) from err
|
||||
if not success:
|
||||
_LOGGER.debug(
|
||||
"%s at %s returned no data", self.device.name, self.device.address
|
||||
)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
|
||||
@@ -87,6 +87,7 @@ from .const import (
|
||||
DEFAULT_RETAIN,
|
||||
DOMAIN,
|
||||
ENTITY_PLATFORMS,
|
||||
ENTRY_OPTION_FIELDS,
|
||||
MQTT_CONNECTION_STATE,
|
||||
PROTOCOL_5,
|
||||
PROTOCOL_311,
|
||||
@@ -153,6 +154,7 @@ __all__ = [
|
||||
"DEFAULT_RETAIN",
|
||||
"DOMAIN",
|
||||
"ENTITY_PLATFORMS",
|
||||
"ENTRY_OPTION_FIELDS",
|
||||
"MQTT",
|
||||
"MQTT_BASE_SCHEMA",
|
||||
"MQTT_CONNECTION_STATE",
|
||||
@@ -466,30 +468,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate the config entry to the latest version."""
|
||||
"""Migrate the options from config entry data."""
|
||||
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
|
||||
data: dict[str, Any] = dict(entry.data)
|
||||
options: dict[str, Any] = dict(entry.options)
|
||||
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
for key in (
|
||||
CONF_DISCOVERY,
|
||||
CONF_DISCOVERY_PREFIX,
|
||||
"birth_message",
|
||||
"will_message",
|
||||
):
|
||||
# Can be removed when the config entry is bumped to version 2.1
|
||||
# with HA Core 2026.7.0. Read support for version 2.1 is expected with 2026.1
|
||||
# From 2026.7 we will write version 2.1
|
||||
for key in ENTRY_OPTION_FIELDS:
|
||||
if key not in data:
|
||||
continue
|
||||
options[key] = data.pop(key)
|
||||
|
||||
# Bump config entry to version 2.1
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=data,
|
||||
options=options,
|
||||
version=2,
|
||||
minor_version=1,
|
||||
)
|
||||
# Write version 1.2 for backwards compatibility
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=data,
|
||||
options=options,
|
||||
version=1,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s.%s successful", entry.version, entry.minor_version
|
||||
|
||||
@@ -273,7 +273,6 @@ ABBREVIATIONS = {
|
||||
"l_ver_t": "latest_version_topic",
|
||||
"l_ver_tpl": "latest_version_template",
|
||||
"pl_inst": "payload_install",
|
||||
"vis": "visible_by_default",
|
||||
}
|
||||
|
||||
DEVICE_ABBREVIATIONS = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
import jinja2
|
||||
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature
|
||||
from homeassistant.const import CONF_PAYLOAD, Platform
|
||||
from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform
|
||||
from homeassistant.exceptions import TemplateError
|
||||
|
||||
ATTR_DISCOVERY_HASH = "discovery_hash"
|
||||
@@ -246,7 +246,6 @@ CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic"
|
||||
CONF_TRANSITION = "transition"
|
||||
CONF_URL_TEMPLATE = "url_template"
|
||||
CONF_URL_TOPIC = "url_topic"
|
||||
CONF_VISIBLE_BY_DEFAULT = "visible_by_default"
|
||||
CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
|
||||
CONF_XY_COMMAND_TOPIC = "xy_command_topic"
|
||||
CONF_XY_STATE_TOPIC = "xy_state_topic"
|
||||
@@ -386,6 +385,17 @@ PAYLOAD_NONE = "None"
|
||||
CONFIG_ENTRY_VERSION = 2
|
||||
CONFIG_ENTRY_MINOR_VERSION = 1
|
||||
|
||||
# Split mqtt entry data and options
|
||||
# Can be removed when config entry is bumped to version 2.1
|
||||
# with HA Core 2026.7.0. Read support for version 2.1 is expected from 2026.1
|
||||
# From 2026.7 we will write version 2.1
|
||||
ENTRY_OPTION_FIELDS = (
|
||||
CONF_DISCOVERY,
|
||||
CONF_DISCOVERY_PREFIX,
|
||||
"birth_message",
|
||||
"will_message",
|
||||
)
|
||||
|
||||
ENTITY_PLATFORMS = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.BINARY_SENSOR,
|
||||
|
||||
@@ -95,7 +95,6 @@ from .const import (
|
||||
CONF_SW_VERSION,
|
||||
CONF_TOPIC,
|
||||
CONF_VIA_DEVICE,
|
||||
CONF_VISIBLE_BY_DEFAULT,
|
||||
DOMAIN,
|
||||
MQTT_CONNECTION_STATE,
|
||||
)
|
||||
@@ -1429,44 +1428,19 @@ class MqttEntity(
|
||||
# Plan to update the entity_id based on `default_entity_id`
|
||||
# if a deleted entity was found
|
||||
self._update_registry_entity_id = self.entity_id
|
||||
|
||||
if (
|
||||
reenable_condition := (
|
||||
deleted_entry
|
||||
and self._config[CONF_ENABLED_BY_DEFAULT]
|
||||
and deleted_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
|
||||
)
|
||||
) or (
|
||||
deleted_entry
|
||||
and self._config[CONF_VISIBLE_BY_DEFAULT]
|
||||
and deleted_entry.hidden_by is not None
|
||||
self._config[CONF_ENABLED_BY_DEFAULT]
|
||||
and deleted_entry
|
||||
and deleted_entry.disabled_by is not None
|
||||
):
|
||||
# Enable previous deleted entity,
|
||||
# if it was not disabled by the user.
|
||||
# Only reset hidden by flag if it was not hidden by the user.
|
||||
if (
|
||||
deleted_entry.hidden_by is er.RegistryEntryHider.USER
|
||||
and self._config[CONF_VISIBLE_BY_DEFAULT]
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Restored entity %s was configured as visible by default, "
|
||||
"but was hidden by the user before, and will remain hidden",
|
||||
self.entity_id,
|
||||
)
|
||||
if deleted_entry.hidden_by is er.RegistryEntryHider.USER:
|
||||
hidden_by: er.RegistryEntryHider | None = er.RegistryEntryHider.USER
|
||||
else:
|
||||
hidden_by = (
|
||||
None
|
||||
if self._config[CONF_VISIBLE_BY_DEFAULT]
|
||||
else er.RegistryEntryHider.INTEGRATION
|
||||
)
|
||||
# Enable previous deleted entity and enable it
|
||||
recreated_entry = entity_registry.async_get_or_create(
|
||||
entity_platform, DOMAIN, self.unique_id
|
||||
)
|
||||
entity_registry.async_update_entity(
|
||||
recreated_entry.entity_id,
|
||||
disabled_by=None if reenable_condition else UNDEFINED,
|
||||
hidden_by=hidden_by,
|
||||
disabled_by=None,
|
||||
)
|
||||
|
||||
if discovery_data is None:
|
||||
@@ -1615,9 +1589,6 @@ class MqttEntity(
|
||||
self._attr_entity_registry_enabled_default = bool(
|
||||
config.get(CONF_ENABLED_BY_DEFAULT, True)
|
||||
)
|
||||
self._attr_entity_registry_visible_default = bool(
|
||||
config.get(CONF_VISIBLE_BY_DEFAULT, True)
|
||||
)
|
||||
self._attr_icon = config.get(CONF_ICON)
|
||||
self._attr_entity_picture = config.get(CONF_ENTITY_PICTURE)
|
||||
# Set the entity name if needed
|
||||
|
||||
@@ -52,7 +52,6 @@ from .const import (
|
||||
CONF_SW_VERSION,
|
||||
CONF_TOPIC,
|
||||
CONF_VIA_DEVICE,
|
||||
CONF_VISIBLE_BY_DEFAULT,
|
||||
DEFAULT_PAYLOAD_AVAILABLE,
|
||||
DEFAULT_PAYLOAD_NOT_AVAILABLE,
|
||||
ENTITY_PLATFORMS,
|
||||
@@ -185,7 +184,6 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string,
|
||||
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
vol.Optional(CONF_VISIBLE_BY_DEFAULT, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -26,31 +26,31 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration has no options flow.
|
||||
docs-installation-parameters: done
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery: done
|
||||
discovery-update-info: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Integration supports a single device per config entry.
|
||||
@@ -71,4 +71,4 @@ rules:
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
strict-typing: todo
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from .const import CONF_STATION_ID
|
||||
from .coordinator import OpenSenseMapConfigEntry, OpenSenseMapCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.AIR_QUALITY, Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [Platform.AIR_QUALITY]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -117,9 +117,9 @@ class OpenSenseMapQuality(CoordinatorEntity[OpenSenseMapCoordinator], AirQuality
|
||||
@property
|
||||
def particulate_matter_2_5(self) -> float | None:
|
||||
"""Return the particulate matter 2.5 level."""
|
||||
return self.coordinator.data.pm2_5.value
|
||||
return self.coordinator.data.pm2_5
|
||||
|
||||
@property
|
||||
def particulate_matter_10(self) -> float | None:
|
||||
"""Return the particulate matter 10 level."""
|
||||
return self.coordinator.data.pm10.value
|
||||
return self.coordinator.data.pm10
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
from opensensemap_api import _TITLES, OpenSenseMap
|
||||
from opensensemap_api import OpenSenseMap
|
||||
from opensensemap_api.exceptions import OpenSenseMapError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -16,68 +14,13 @@ from .const import DOMAIN, LOGGER
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
# Stations report the same phenomenon in different units, but the library
|
||||
# exposes only values. These map a station's reported unit (normalized to
|
||||
# lowercase) to the matching Home Assistant unit so values convert correctly.
|
||||
TEMPERATURE_UNITS: dict[str, str] = {
|
||||
"°c": UnitOfTemperature.CELSIUS,
|
||||
"c": UnitOfTemperature.CELSIUS,
|
||||
"°f": UnitOfTemperature.FAHRENHEIT,
|
||||
"f": UnitOfTemperature.FAHRENHEIT,
|
||||
}
|
||||
WIND_SPEED_UNITS: dict[str, str] = {
|
||||
"m/s": UnitOfSpeed.METERS_PER_SECOND,
|
||||
"km/h": UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
"mph": UnitOfSpeed.MILES_PER_HOUR,
|
||||
}
|
||||
PRESSURE_UNITS: dict[str, str] = {
|
||||
"hpa": UnitOfPressure.HPA,
|
||||
"pa": UnitOfPressure.PA,
|
||||
"pascal": UnitOfPressure.PA,
|
||||
"mbar": UnitOfPressure.MBAR,
|
||||
"kpa": UnitOfPressure.KPA,
|
||||
}
|
||||
|
||||
|
||||
class Measurement(NamedTuple):
|
||||
"""A station measurement paired with its detected unit, if any."""
|
||||
|
||||
value: float | None
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class OpenSenseMapStationData:
|
||||
"""Immutable measurements for an openSenseMap station."""
|
||||
|
||||
pm2_5: Measurement
|
||||
pm10: Measurement
|
||||
pm1_0: Measurement
|
||||
temperature: Measurement
|
||||
humidity: Measurement
|
||||
air_pressure: Measurement
|
||||
illuminance: Measurement
|
||||
wind_speed: Measurement
|
||||
wind_direction: Measurement
|
||||
|
||||
|
||||
def _detect_unit(
|
||||
api: OpenSenseMap, title_key: str, unit_map: dict[str, str]
|
||||
) -> str | None:
|
||||
"""Return the Home Assistant unit for a phenomenon reported by the station."""
|
||||
|
||||
# The library resolves a measurement by matching localized sensor titles
|
||||
# (opensensemap_api._TITLES) and returns the first matching sensor that has a
|
||||
# value. Mirror that approach to find the matching unit.
|
||||
for title in (*_TITLES.get(title_key, ()), title_key):
|
||||
for sensor in api.data.get("sensors", []):
|
||||
measurement = sensor.get("lastMeasurement") or {}
|
||||
if (
|
||||
sensor.get("title", "").casefold() == title.casefold()
|
||||
and measurement.get("value") is not None
|
||||
):
|
||||
return unit_map.get((sensor.get("unit") or "").strip().casefold())
|
||||
return None
|
||||
pm2_5: float | None
|
||||
pm10: float | None
|
||||
|
||||
|
||||
type OpenSenseMapConfigEntry = ConfigEntry[OpenSenseMapCoordinator]
|
||||
@@ -112,23 +55,4 @@ class OpenSenseMapCoordinator(DataUpdateCoordinator[OpenSenseMapStationData]):
|
||||
raise UpdateFailed(
|
||||
f"Unable to fetch data from openSenseMap: {err}"
|
||||
) from err
|
||||
return OpenSenseMapStationData(
|
||||
pm2_5=Measurement(self.api.pm2_5),
|
||||
pm10=Measurement(self.api.pm10),
|
||||
pm1_0=Measurement(self.api.pm1_0),
|
||||
temperature=Measurement(
|
||||
self.api.temperature,
|
||||
_detect_unit(self.api, "Temperature", TEMPERATURE_UNITS),
|
||||
),
|
||||
humidity=Measurement(self.api.humidity),
|
||||
air_pressure=Measurement(
|
||||
self.api.air_pressure,
|
||||
_detect_unit(self.api, "Air Pressure", PRESSURE_UNITS),
|
||||
),
|
||||
illuminance=Measurement(self.api.illuminance),
|
||||
wind_speed=Measurement(
|
||||
self.api.wind_speed,
|
||||
_detect_unit(self.api, "Wind Speed", WIND_SPEED_UNITS),
|
||||
),
|
||||
wind_direction=Measurement(self.api.wind_direction),
|
||||
)
|
||||
return OpenSenseMapStationData(pm2_5=self.api.pm2_5, pm10=self.api.pm10)
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Support for openSenseMap sensors."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
DEGREE,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
UnitOfPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_STATION_ID, DOMAIN, INTEGRATION_TITLE
|
||||
from .coordinator import (
|
||||
Measurement,
|
||||
OpenSenseMapConfigEntry,
|
||||
OpenSenseMapCoordinator,
|
||||
OpenSenseMapStationData,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OpenSenseMapSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes openSenseMap sensor entities."""
|
||||
|
||||
value_fn: Callable[[OpenSenseMapStationData], Measurement]
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[OpenSenseMapSensorEntityDescription, ...] = (
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="pm2_5",
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.pm2_5,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="pm10",
|
||||
device_class=SensorDeviceClass.PM10,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.pm10,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="pm1_0",
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.pm1_0,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.temperature,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.humidity,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="air_pressure",
|
||||
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.HPA,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.air_pressure,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="illuminance",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.illuminance,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="wind_speed",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.wind_speed,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="wind_direction",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
native_unit_of_measurement=DEGREE,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
value_fn=lambda data: data.wind_direction,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OpenSenseMapConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up openSenseMap sensors from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities: list[OpenSenseMapSensor] = []
|
||||
for description in SENSOR_DESCRIPTIONS:
|
||||
measurement = description.value_fn(coordinator.data)
|
||||
if measurement.value is None:
|
||||
continue
|
||||
native_unit = measurement.unit or description.native_unit_of_measurement
|
||||
entities.append(OpenSenseMapSensor(coordinator, description, native_unit))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class OpenSenseMapSensor(CoordinatorEntity[OpenSenseMapCoordinator], SensorEntity):
|
||||
"""Sensor entity representing a single measurement from an openSenseMap station."""
|
||||
|
||||
_attr_attribution = "Data provided by openSenseMap"
|
||||
_attr_has_entity_name = True
|
||||
entity_description: OpenSenseMapSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: OpenSenseMapCoordinator,
|
||||
description: OpenSenseMapSensorEntityDescription,
|
||||
native_unit: str | None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_native_unit_of_measurement = native_unit
|
||||
station_id = coordinator.config_entry.data[CONF_STATION_ID]
|
||||
self._attr_unique_id = f"{station_id}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, station_id)},
|
||||
manufacturer=INTEGRATION_TITLE,
|
||||
configuration_url=f"https://opensensemap.org/explore/{station_id}",
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | str | None:
|
||||
"""Return the latest value reported by the station."""
|
||||
return self.entity_description.value_fn(self.coordinator.data).value
|
||||
@@ -16,6 +16,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ATTR_CH_OVRD,
|
||||
@@ -73,7 +74,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
{
|
||||
vol.Required(ATTR_GW_ID): vol.All(cv.string),
|
||||
vol.Optional(ATTR_DATE, default=date.today): cv.date,
|
||||
vol.Optional(ATTR_TIME, default=lambda: datetime.now().time()): cv.time,
|
||||
vol.Optional(
|
||||
ATTR_TIME, default=lambda: dt_util.naive_now().time()
|
||||
): cv.time,
|
||||
}
|
||||
)
|
||||
service_set_control_setpoint_schema = vol.Schema(
|
||||
|
||||
@@ -8,16 +8,11 @@ from time import time
|
||||
from typing import Any
|
||||
|
||||
from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, RETRY_ATTEMPTS
|
||||
from reolink_aio.const import UNKNOWN
|
||||
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
|
||||
|
||||
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
@@ -34,7 +29,6 @@ from .const import (
|
||||
CONF_BC_PORT,
|
||||
CONF_FIRMWARE_CHECK_TIME,
|
||||
CONF_SUPPORTS_PRIVACY_MODE,
|
||||
CONF_UID,
|
||||
CONF_USE_HTTPS,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -101,22 +95,6 @@ async def async_setup_entry(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop)
|
||||
)
|
||||
|
||||
# do not allow changes to the UID
|
||||
if (
|
||||
config_entry.data.get(CONF_UID, host.api.uid) != host.api.uid
|
||||
and config_entry.data.get(CONF_UID) != UNKNOWN
|
||||
):
|
||||
await host.stop()
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="uid_mismatch",
|
||||
translation_placeholders={
|
||||
"name": host.api.nvr_name,
|
||||
"conf_uid": config_entry.data.get(CONF_UID, ""),
|
||||
"uid": host.api.uid,
|
||||
},
|
||||
)
|
||||
|
||||
# update the config info if needed for the next time
|
||||
if (
|
||||
host.api.port != config_entry.data[CONF_PORT]
|
||||
@@ -127,7 +105,6 @@ async def async_setup_entry(
|
||||
or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY)
|
||||
or host.api.baichuan.connection_type.value
|
||||
!= config_entry.data.get(CONF_BC_CONNECT)
|
||||
or host.api.uid != config_entry.data.get(CONF_UID)
|
||||
):
|
||||
if host.api.port != config_entry.data[CONF_PORT]:
|
||||
_LOGGER.warning(
|
||||
@@ -153,7 +130,6 @@ async def async_setup_entry(
|
||||
CONF_BC_PORT: host.api.baichuan.port,
|
||||
CONF_BC_ONLY: host.api.baichuan_only,
|
||||
CONF_BC_CONNECT: host.api.baichuan.connection_type.value,
|
||||
CONF_UID: host.api.uid,
|
||||
CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
|
||||
}
|
||||
hass.config_entries.async_update_entry(config_entry, data=data)
|
||||
|
||||
@@ -41,7 +41,6 @@ from .const import (
|
||||
CONF_BC_ONLY,
|
||||
CONF_BC_PORT,
|
||||
CONF_SUPPORTS_PRIVACY_MODE,
|
||||
CONF_UID,
|
||||
CONF_USE_HTTPS,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -313,7 +312,6 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_BC_PORT] = host.api.baichuan.port
|
||||
user_input[CONF_BC_ONLY] = host.api.baichuan_only
|
||||
user_input[CONF_BC_CONNECT] = host.api.baichuan.connection_type.value
|
||||
user_input[CONF_UID] = host.api.uid
|
||||
user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported(
|
||||
None, "privacy_mode"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ CONF_BC_ONLY = "baichuan_only"
|
||||
CONF_BC_CONNECT = "baichuan_connection"
|
||||
CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"
|
||||
CONF_FIRMWARE_CHECK_TIME = "firmware_check_time"
|
||||
CONF_UID = "uid"
|
||||
|
||||
# Conserve battery by not waking the battery cameras each minute during normal update
|
||||
# Most props are cached in the Home Hub and updated, but some are skipped
|
||||
|
||||
@@ -11,7 +11,6 @@ import aiohttp
|
||||
from aiohttp.web import Request
|
||||
from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host
|
||||
from reolink_aio.baichuan import DEFAULT_BC_PORT
|
||||
from reolink_aio.const import UNKNOWN
|
||||
from reolink_aio.enums import ConnectionEnum, SubType
|
||||
from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
|
||||
|
||||
@@ -41,7 +40,6 @@ from .const import (
|
||||
CONF_BC_ONLY,
|
||||
CONF_BC_PORT,
|
||||
CONF_SUPPORTS_PRIVACY_MODE,
|
||||
CONF_UID,
|
||||
CONF_USE_HTTPS,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -107,7 +105,6 @@ class ReolinkHost:
|
||||
bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT),
|
||||
bc_connection=bc_connection,
|
||||
bc_only=config.get(CONF_BC_ONLY, False),
|
||||
uid=config.get(CONF_UID, UNKNOWN),
|
||||
)
|
||||
|
||||
self.last_wake: defaultdict[int, float] = defaultdict(float)
|
||||
@@ -938,8 +935,6 @@ class ReolinkHost:
|
||||
def event_connection(self) -> str:
|
||||
"""Type of connection to receive events."""
|
||||
if self._api.baichuan.events_active:
|
||||
if self._api.baichuan.webhook_subscribed:
|
||||
return "Webhook push"
|
||||
return "TCP push"
|
||||
if self._webhook_reachable:
|
||||
return "ONVIF push"
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.21.0"]
|
||||
"requirements": ["reolink-aio==0.20.1"]
|
||||
}
|
||||
|
||||
@@ -918,9 +918,6 @@
|
||||
"timeout": {
|
||||
"message": "Timeout waiting on a response: {err}"
|
||||
},
|
||||
"uid_mismatch": {
|
||||
"message": "UID {uid} of Reolink camera \"{name}\" did not match the stored configuration UID {conf_uid}, please check the connection details"
|
||||
},
|
||||
"unexpected": {
|
||||
"message": "Unexpected Reolink error: {err}"
|
||||
},
|
||||
|
||||
@@ -63,21 +63,11 @@ CODE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
ARM_HOME_MODE_OPTIONS = ["1", "2", "3"]
|
||||
|
||||
PARTITION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.All(
|
||||
vol.Coerce(str),
|
||||
selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=ARM_HOME_MODE_OPTIONS,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
translation_key="arm_home_mode",
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
vol.Required(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.In(
|
||||
[1, 2, 3]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Constants for the Satel Integra integration."""
|
||||
|
||||
DEFAULT_CONF_ARM_HOME_MODE = "1"
|
||||
DEFAULT_CONF_ARM_HOME_MODE = 1
|
||||
DEFAULT_PORT = 7094
|
||||
|
||||
DOMAIN = "satel_integra"
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
"partition_number": "Partition number"
|
||||
},
|
||||
"data_description": {
|
||||
"arm_home_mode": "The arming mode to use for 'arm home':\nMode 1 fully arms and bypasses zones that have the 'Bypassed if no exit' option enabled.\nMode 2 disarms interior zones; exterior zones trigger silent alarms and other alarm zones trigger loud alarms.\nMode 3 is like mode 2, but delayed zones are instant.",
|
||||
"arm_home_mode": "The mode in which the partition is armed when 'arm home' is used. For more information on what the differences are between them, please refer to Satel Integra manual.",
|
||||
"name": "The name to give to the alarm panel",
|
||||
"partition_number": "Enter partition number to configure"
|
||||
},
|
||||
@@ -223,13 +223,6 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"arm_home_mode": {
|
||||
"options": {
|
||||
"1": "1 - Full arming + bypasses",
|
||||
"2": "2 - No interior zones",
|
||||
"3": "3 - No interior zones or entry delay"
|
||||
}
|
||||
},
|
||||
"binary_sensor_device_class": {
|
||||
"options": {
|
||||
"battery": "[%key:component::binary_sensor::entity_component::battery::name%]",
|
||||
|
||||
@@ -51,7 +51,7 @@ class ShoppingTodoListEntity(TodoListEntity):
|
||||
)
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Update an item in the To-do list."""
|
||||
"""Update an item to the To-do list."""
|
||||
data = {
|
||||
"name": item.summary,
|
||||
"complete": item.status == TodoItemStatus.COMPLETED,
|
||||
@@ -64,7 +64,7 @@ class ShoppingTodoListEntity(TodoListEntity):
|
||||
) from err
|
||||
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Delete items from the To-do list."""
|
||||
"""Add an item to the To-do list."""
|
||||
await self._data.async_remove_items(set(uids))
|
||||
|
||||
async def async_move_todo_item(
|
||||
|
||||
@@ -170,37 +170,6 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
return result
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfigure flow."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_SERVER: user_input[CONF_SERVER],
|
||||
CONF_SENDER: user_input[CONF_SENDER],
|
||||
CONF_USERNAME: user_input.get(CONF_USERNAME),
|
||||
}
|
||||
)
|
||||
errors = await self.hass.async_add_executor_job(validate_input, user_input)
|
||||
if not errors:
|
||||
return self.async_update_and_abort(
|
||||
entry,
|
||||
data=user_input,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
suggested_values=user_input or entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import config from yaml."""
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -11,29 +10,6 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"encryption": "[%key:component::smtp::config::step::user::data::encryption%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"sender": "[%key:component::smtp::config::step::user::data::sender%]",
|
||||
"sender_name": "[%key:component::smtp::config::step::user::data::sender_name%]",
|
||||
"server": "[%key:common::config_flow::data::host%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"encryption": "[%key:component::smtp::config::step::user::data_description::encryption%]",
|
||||
"password": "[%key:component::smtp::config::step::user::data_description::password%]",
|
||||
"port": "[%key:component::smtp::config::step::user::data_description::port%]",
|
||||
"sender": "[%key:component::smtp::config::step::user::data_description::sender%]",
|
||||
"sender_name": "[%key:component::smtp::config::step::user::data_description::sender_name%]",
|
||||
"server": "[%key:component::smtp::config::step::user::data_description::server%]",
|
||||
"username": "[%key:component::smtp::config::step::user::data_description::username%]",
|
||||
"verify_ssl": "[%key:component::smtp::config::step::user::data_description::verify_ssl%]"
|
||||
},
|
||||
"title": "Reconfigure SMTP"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"encryption": "Connection security",
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_FALLBACK,
|
||||
@@ -156,7 +155,9 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
# Tado resets somewhere between 12:00 and 13:00, Berlin time
|
||||
# So let's pretend we're in Berlin...
|
||||
reset_time = dt_util.now(ZoneInfo("Europe/Berlin"))
|
||||
reset_time = datetime.now( # pylint: disable=home-assistant-enforce-now
|
||||
ZoneInfo("Europe/Berlin")
|
||||
)
|
||||
|
||||
today_reset = datetime.combine(
|
||||
reset_time.date(),
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
"name": "Rename item"
|
||||
},
|
||||
"status": {
|
||||
"description": "A status for the to-do item.",
|
||||
"description": "A status or confirmation of the to-do item.",
|
||||
"name": "Set status"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,10 +8,8 @@ from homeassistant.const import Platform
|
||||
|
||||
LOGGER: Logger = getLogger(__package__)
|
||||
|
||||
# The free plan is formally limited to 10 requests/minute
|
||||
# But real world says 5 requests/minute is the real limit
|
||||
# Opened a ticket with support with no response for 2 months
|
||||
COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=15)
|
||||
# The free plan is limited to 10 requests/minute
|
||||
COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=10)
|
||||
|
||||
DOMAIN: Final = "uptimerobot"
|
||||
PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/v2c",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pytrydan==1.0.2"]
|
||||
"requirements": ["pytrydan==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["verisure"],
|
||||
"requirements": ["vsure==2.7.1"]
|
||||
"requirements": ["vsure==2.7.0"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ from .coordinator import VistapoolDataUpdateCoordinator
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user