Compare commits

..

3 Commits

Author SHA1 Message Date
Martin Hjelmare 42308934e1 Clarify local time 2026-06-10 16:43:35 +02:00
Martin Hjelmare 62ca3d30d3 Use naive_now in one place 2026-06-10 13:55:14 +02:00
Martin Hjelmare 991a05f797 Add util.dt.naive_now 2026-06-10 13:52:15 +02:00
188 changed files with 812 additions and 11098 deletions
+19 -17
View File
@@ -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:
+251 -243
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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,
)
+1 -16
View File
@@ -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": {
+2 -4
View File
@@ -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",
),
)
-10
View File
@@ -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",
}
+3 -3
View File
@@ -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."]
}
+1 -15
View File
@@ -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",
+10 -18
View File
@@ -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"
+14 -18
View File
@@ -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"]
}
+1 -1
View File
@@ -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": {
+5 -21
View File
@@ -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 -48
View File
@@ -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"
},
+3 -87
View File
@@ -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.
+19 -23
View File
@@ -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(
-106
View File
@@ -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
+2 -2
View File
@@ -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:
+18 -36
View File
@@ -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",
+15 -16
View File
@@ -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 = {
+12 -2
View File
@@ -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,
+6 -35
View File
@@ -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
-2
View File
@@ -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(
+1 -25
View File
@@ -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
-5
View File
@@ -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 -25
View File
@@ -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",
+3 -2
View File
@@ -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(),
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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