mirror of
https://github.com/home-assistant/core.git
synced 2026-06-16 17:02:57 +02:00
Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ad8ad5715 | |||
| a45867b896 | |||
| 000e075a8e | |||
| 0899d016b9 | |||
| 3375f2ed76 | |||
| 3f5778e71b | |||
| 86c39694d3 | |||
| a53a6644c0 | |||
| 18fdfacf45 | |||
| bd9bd29f2c | |||
| 334c6614cc | |||
| aa772f6ecd | |||
| 87169921ae | |||
| 16338b8b6b | |||
| 519da3c9c9 | |||
| 6f34718c1f | |||
| e4287bb43c | |||
| d724ebac2a | |||
| dc480051db | |||
| 63b6ced9c4 | |||
| 34e9b3ff1e | |||
| 210746525e | |||
| 0134e99366 | |||
| 06de89d6a3 | |||
| 4c267617f8 | |||
| a82f1a7a1d | |||
| d234f65dd9 | |||
| 30148980e1 | |||
| 1fa9a3353c | |||
| 2dbbd70085 | |||
| 73903b0bfc | |||
| b09f54ce3b | |||
| 6d9e41da07 | |||
| f5600a602f | |||
| d83cd941a7 | |||
| 2120cad533 | |||
| fb4e72af77 | |||
| badd4130b6 | |||
| 7a4ca4dcfd | |||
| 9b47a0d440 | |||
| 4b99e81a8a | |||
| 62e5238f43 | |||
| 149c884a89 | |||
| 71ca453c42 | |||
| aad6080307 | |||
| 2db2e0b0cf | |||
| 3fc36ab6f9 | |||
| 0fad24393c | |||
| a992a58367 | |||
| f0cefe2f2e | |||
| 40264992a2 | |||
| c29aebd60e | |||
| 36b74d6f05 | |||
| 2c626fa8f0 | |||
| cab0d015f6 | |||
| c544f95979 | |||
| 2189d0ae74 | |||
| 9e96a06aff | |||
| d16e0e9867 | |||
| 2209996919 | |||
| d88767155b | |||
| 334d02077f | |||
| 2b7e9289d2 | |||
| c57358dd23 | |||
| e151478d78 | |||
| e41b1f5279 | |||
| 4203aed863 | |||
| e7e116843f | |||
| d781baca7e | |||
| 855962dcd0 | |||
| cf914f559f | |||
| a420a6c990 | |||
| 5f470d49a5 | |||
| bd2638f144 | |||
| b397d6fd05 | |||
| eb2ee43e6f | |||
| 9d16e59899 | |||
| 2434341e04 | |||
| 047edc035d | |||
| 8b5f27e016 | |||
| 5200a8131f | |||
| 2dc1870ecd | |||
| d8f125dfe9 | |||
| 311cd56c93 | |||
| 4b17e3abcb | |||
| f2839bbf7a | |||
| 0229545184 | |||
| e8ce995560 | |||
| 46ffb3bd95 | |||
| 27677a07a6 | |||
| f619ccca4b | |||
| 09a72ac505 | |||
| 27573c5231 | |||
| d5f23fffa8 | |||
| 3b70ac987d | |||
| e00b8f154e | |||
| abc751fd1c | |||
| 6b5c7ec864 | |||
| d63bb48040 | |||
| b71b155ffb | |||
| 0f59a6070f | |||
| bb34887983 | |||
| 6a06873527 | |||
| c012acc685 | |||
| 735ef5fc14 | |||
| 405b9db101 | |||
| 57aede0e27 | |||
| c9d7d842ff | |||
| 9e8af2d098 | |||
| 90dc3717b0 | |||
| a7c70d4d26 | |||
| 1dc5f1b768 | |||
| e9f4bea715 | |||
| f2aa8aa73d | |||
| 6f0831ebbb | |||
| 579fbd2ae8 | |||
| e056c7d78c |
@@ -6,6 +6,7 @@
|
||||
|
||||
- Start review comments with a short, one-sentence summary of the suggested fix.
|
||||
- Do not comment on code style, formatting or linting issues.
|
||||
- Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line).
|
||||
- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR.
|
||||
|
||||
# GitHub Copilot & Claude Code Instructions
|
||||
@@ -50,4 +51,5 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
|
||||
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
|
||||
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
|
||||
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
|
||||
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
|
||||
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
|
||||
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build machine image
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
|
||||
@@ -50,19 +50,24 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Install script dependencies
|
||||
run: pip install -r script/check_requirements/requirements.txt
|
||||
- name: Collect PR diff
|
||||
- name: Collect PR diff and head SHA
|
||||
id: pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
mkdir -p deterministic
|
||||
gh pr diff "${PR_NUMBER}" > deterministic/pr.diff
|
||||
HEAD_SHA=$(gh pr view "${PR_NUMBER}" --json headRefOid --jq '.headRefOid')
|
||||
echo "head_sha=${HEAD_SHA}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Run deterministic checks
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
run: |
|
||||
python -m script.check_requirements \
|
||||
--pr-number "${PR_NUMBER}" \
|
||||
--head-sha "${HEAD_SHA}" \
|
||||
--diff deterministic/pr.diff \
|
||||
--output deterministic/results.json
|
||||
- name: Upload deterministic-results artifact
|
||||
|
||||
+77
-3
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"7b142e96e0f8b454cdcc9c0c25070cf9a52c44d83a6b1fbc3ad6725b6567337c","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# 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":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"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":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -345,6 +345,7 @@ jobs:
|
||||
needs:
|
||||
- activation
|
||||
- extract_pr_number
|
||||
- gate
|
||||
if: needs.activation.outputs.daily_effective_workflow_exceeded != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -994,6 +995,7 @@ jobs:
|
||||
- agent
|
||||
- detection
|
||||
- extract_pr_number
|
||||
- gate
|
||||
- safe_outputs
|
||||
if: >
|
||||
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
|
||||
@@ -1428,8 +1430,8 @@ jobs:
|
||||
}
|
||||
|
||||
extract_pr_number:
|
||||
needs: activation
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
needs: gate
|
||||
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -1460,6 +1462,78 @@ jobs:
|
||||
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
|
||||
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
gate:
|
||||
needs: activation
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
outputs:
|
||||
skip: ${{ steps.gate.outputs.skip }}
|
||||
steps:
|
||||
- name: Configure GH_HOST for enterprise compatibility
|
||||
id: ghes-host-config
|
||||
shell: bash
|
||||
# zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
|
||||
run: |
|
||||
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
|
||||
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
|
||||
GH_HOST="${GITHUB_SERVER_URL#https://}"
|
||||
GH_HOST="${GH_HOST#http://}"
|
||||
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
|
||||
- name: Download deterministic-results artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/gate
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
- name: Decide whether requirements changed since the last comment
|
||||
id: gate
|
||||
run: |
|
||||
PR=$(jq -r '.pr_number' /tmp/gate/results.json)
|
||||
HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json)
|
||||
if [ -z "${HEAD}" ]; then
|
||||
echo "Artifact has no head_sha; running the agent."
|
||||
exit 0
|
||||
fi
|
||||
# Recover the commit recorded in the most recent requirements-check
|
||||
# comment from the "Checked at commit" link
|
||||
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
|
||||
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
|
||||
| grep -oiE '/commit/[0-9a-f]{40}' \
|
||||
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
|
||||
if [ -z "${PRIOR}" ]; then
|
||||
echo "No previous comment with a recorded commit; running the agent."
|
||||
exit 0
|
||||
fi
|
||||
if [ "${PRIOR}" = "${HEAD}" ]; then
|
||||
echo "Head ${HEAD} unchanged since the last comment; skipping the agent."
|
||||
echo "skip=true" >> "${GITHUB_OUTPUT}"
|
||||
exit 0
|
||||
fi
|
||||
# List files changed between the recorded commit and the current head.
|
||||
# Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS.
|
||||
CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \
|
||||
--jq '.files[].filename' 2>/dev/null) || {
|
||||
echo "Could not compare ${PRIOR}...${HEAD}; running the agent."
|
||||
exit 0
|
||||
}
|
||||
TRACKED=$(printf '%s\n' "${CHANGED}" \
|
||||
| grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true)
|
||||
if [ -z "${TRACKED}" ]; then
|
||||
echo "No tracked requirement files changed since ${PRIOR}; skipping the agent."
|
||||
echo "skip=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "Tracked requirement files changed since ${PRIOR}; running the agent:"
|
||||
printf '%s\n' "${TRACKED}"
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
pre_activation:
|
||||
runs-on: ubuntu-slim
|
||||
outputs:
|
||||
|
||||
@@ -22,9 +22,70 @@ safe-outputs:
|
||||
needs:
|
||||
- extract_pr_number
|
||||
jobs:
|
||||
extract_pr_number:
|
||||
gate:
|
||||
# Skip the (token-spending) agent when no tracked requirement file changed
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
skip: ${{ steps.gate.outputs.skip }}
|
||||
steps:
|
||||
- name: Download deterministic-results artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: check-requirements-deterministic
|
||||
path: /tmp/gate
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Decide whether requirements changed since the last comment
|
||||
id: gate
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR=$(jq -r '.pr_number' /tmp/gate/results.json)
|
||||
HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json)
|
||||
if [ -z "${HEAD}" ]; then
|
||||
echo "Artifact has no head_sha; running the agent."
|
||||
exit 0
|
||||
fi
|
||||
# Recover the commit recorded in the most recent requirements-check
|
||||
# comment from the "Checked at commit" link
|
||||
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
|
||||
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
|
||||
| grep -oiE '/commit/[0-9a-f]{40}' \
|
||||
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
|
||||
if [ -z "${PRIOR}" ]; then
|
||||
echo "No previous comment with a recorded commit; running the agent."
|
||||
exit 0
|
||||
fi
|
||||
if [ "${PRIOR}" = "${HEAD}" ]; then
|
||||
echo "Head ${HEAD} unchanged since the last comment; skipping the agent."
|
||||
echo "skip=true" >> "${GITHUB_OUTPUT}"
|
||||
exit 0
|
||||
fi
|
||||
# List files changed between the recorded commit and the current head.
|
||||
# Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS.
|
||||
CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \
|
||||
--jq '.files[].filename' 2>/dev/null) || {
|
||||
echo "Could not compare ${PRIOR}...${HEAD}; running the agent."
|
||||
exit 0
|
||||
}
|
||||
TRACKED=$(printf '%s\n' "${CHANGED}" \
|
||||
| grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true)
|
||||
if [ -z "${TRACKED}" ]; then
|
||||
echo "No tracked requirement files changed since ${PRIOR}; skipping the agent."
|
||||
echo "skip=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "Tracked requirement files changed since ${PRIOR}; running the agent:"
|
||||
printf '%s\n' "${TRACKED}"
|
||||
fi
|
||||
extract_pr_number:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
outputs:
|
||||
|
||||
@@ -102,7 +102,7 @@ repos:
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|homeassistant/components/sensor/const\.py|requirements.+\.txt)$
|
||||
- id: hassfest-metadata
|
||||
name: hassfest-metadata
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
|
||||
|
||||
@@ -40,4 +40,5 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
|
||||
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
|
||||
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
|
||||
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
|
||||
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
|
||||
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
|
||||
|
||||
Generated
+5
-3
@@ -262,8 +262,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/braviatv/ @bieniu @Drafteed
|
||||
/homeassistant/components/bring/ @miaucl @tr4nt0r
|
||||
/tests/components/bring/ @miaucl @tr4nt0r
|
||||
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
|
||||
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
|
||||
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am
|
||||
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am
|
||||
/homeassistant/components/brother/ @bieniu
|
||||
/tests/components/brother/ @bieniu
|
||||
/homeassistant/components/brottsplatskartan/ @gjohansson-ST
|
||||
@@ -695,6 +695,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/gree/ @cmroche
|
||||
/homeassistant/components/green_planet_energy/ @petschni
|
||||
/tests/components/green_planet_energy/ @petschni
|
||||
/homeassistant/components/greencell/ @BrzezowskiGC
|
||||
/tests/components/greencell/ @BrzezowskiGC
|
||||
/homeassistant/components/greeneye_monitor/ @jkeljo
|
||||
/tests/components/greeneye_monitor/ @jkeljo
|
||||
/homeassistant/components/group/ @home-assistant/core
|
||||
@@ -1155,7 +1157,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/motionmount/ @laiho-vogels
|
||||
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
|
||||
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
|
||||
/homeassistant/components/msteams/ @peroyvind
|
||||
/homeassistant/components/mta/ @OnFreund
|
||||
/tests/components/mta/ @OnFreund
|
||||
/homeassistant/components/mullvad/ @meichthys
|
||||
@@ -1891,6 +1892,7 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/unifi_access/ @imhotep @RaHehl
|
||||
/tests/components/unifi_access/ @imhotep @RaHehl
|
||||
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
|
||||
/tests/components/unifi_direct/ @tofuSCHNITZEL
|
||||
/homeassistant/components/unifi_discovery/ @RaHehl
|
||||
/tests/components/unifi_discovery/ @RaHehl
|
||||
/homeassistant/components/unifiled/ @florisvdk
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"microsoft_face_identify",
|
||||
"microsoft_face",
|
||||
"microsoft",
|
||||
"msteams",
|
||||
"onedrive",
|
||||
"onedrive_for_business",
|
||||
"xbox"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairq"],
|
||||
"requirements": ["aioairq==0.4.7"],
|
||||
"requirements": ["aioairq==0.4.8"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"title": "Re-authenticate AirVisual"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"type": "Integration type"
|
||||
},
|
||||
"description": "Pick what type of AirVisual data you want to monitor.",
|
||||
"title": "Configure AirVisual"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"""The AirVisual Pro integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -32,7 +28,7 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
|
||||
connections={
|
||||
(
|
||||
CONNECTION_NETWORK_MAC,
|
||||
format_mac(self.coordinator.data["status"]["mac_address"]),
|
||||
self.coordinator.data["status"]["mac_address"],
|
||||
)
|
||||
},
|
||||
manufacturer="AirVisual",
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.const import CONF_MAC, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -87,9 +87,12 @@ class AnthemAVR(MediaPlayerEntity):
|
||||
via_device=(DOMAIN, mac_address),
|
||||
)
|
||||
else:
|
||||
# Zone 1 is the physical receiver that owns the network MAC; higher
|
||||
# zones are via_device children and carry no connection.
|
||||
self._attr_unique_id = mac_address
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac_address)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac_address)},
|
||||
name=name,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=model,
|
||||
|
||||
@@ -52,10 +52,7 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
Service integration, no discovery.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
No data updates.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
|
||||
@@ -193,6 +193,7 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, data[Attribute.MAC_ADDRESS])},
|
||||
name=self.create_device_name(data),
|
||||
manufacturer="Aprilaire",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Config flow for Aquacell integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -31,6 +32,12 @@ DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Aquacell."""
|
||||
@@ -77,3 +84,48 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauth dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
api = AquacellApi(
|
||||
session, reauth_entry.data.get(CONF_BRAND, Brand.AQUACELL)
|
||||
)
|
||||
try:
|
||||
refresh_token = await api.authenticate(
|
||||
reauth_entry.data[CONF_EMAIL], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except ApiException, TimeoutError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except AuthenticationFailed:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_REFRESH_TOKEN: refresh_token,
|
||||
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_SCHEMA,
|
||||
description_placeholders={"email": reauth_entry.data[CONF_EMAIL]},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from aioaquacell import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
@@ -79,7 +79,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
|
||||
|
||||
softeners = await self.aquacell_api.get_all_softeners()
|
||||
except AuthenticationFailed as err:
|
||||
raise ConfigEntryError from err
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (AquacellApiException, TimeoutError) as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -9,6 +10,13 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"description": "The password for {email} is no longer valid. Enter your current softener mobile app password to reconnect.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"brand": "Brand",
|
||||
|
||||
@@ -59,7 +59,9 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._get_reconfigure_entry(), data_updates=user_input
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Aqvify", data=user_input)
|
||||
return self.async_create_entry(
|
||||
title=account_data.name or "Aqvify", data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -49,6 +50,7 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
|
||||
self.api_client = AqvifyAPI(
|
||||
entry.data[CONF_API_KEY], websession=async_get_clientsession(hass)
|
||||
)
|
||||
self.previous_devices: set[str] = set()
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
@@ -102,10 +104,25 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
|
||||
},
|
||||
) from err
|
||||
|
||||
current_devices = set(devices.devices.keys())
|
||||
if stale_devices := self.previous_devices - current_devices:
|
||||
account_id = self.config_entry.unique_id
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_id in stale_devices:
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{account_id}_{device_id}")}
|
||||
)
|
||||
if device:
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
self.previous_devices = current_devices
|
||||
|
||||
device_data = {}
|
||||
for device in devices.devices.values():
|
||||
for aqvify_device in devices.devices.values():
|
||||
try:
|
||||
device_key = str(device.device_key)
|
||||
device_key = str(aqvify_device.device_key)
|
||||
device_data[
|
||||
device_key
|
||||
] = await self.api_client.async_get_device_latest_data(device_key)
|
||||
@@ -135,3 +152,10 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
|
||||
devices=devices,
|
||||
device_data=device_data,
|
||||
)
|
||||
|
||||
def async_add_devices(self, added_devices: set[str]) -> tuple[set[str], set[str]]:
|
||||
"""Return newly discovered device keys and the full current device set."""
|
||||
|
||||
current_devices = set(self.data.devices.devices)
|
||||
new_devices: set[str] = current_devices - added_devices
|
||||
return (new_devices, current_devices)
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyaqvify"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyaqvify==0.0.9"]
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyaqvify==0.0.11"]
|
||||
}
|
||||
|
||||
@@ -29,16 +29,28 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
There are no configuration options.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: done
|
||||
comment: |
|
||||
Handled by coordinator.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: |
|
||||
Handled by coordinator.
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: todo
|
||||
|
||||
@@ -59,11 +59,23 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Aqvify sensor entities from a config entry."""
|
||||
async_add_entities(
|
||||
AqvifySensor(entry.runtime_data, description, device_key)
|
||||
for description in ENTITIES
|
||||
for device_key in entry.runtime_data.data.devices.devices
|
||||
)
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
added_devices: set[str] = set()
|
||||
|
||||
def _async_add_new_devices() -> None:
|
||||
nonlocal added_devices
|
||||
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
|
||||
added_devices = current_devices
|
||||
|
||||
async_add_entities(
|
||||
AqvifySensor(coordinator, description, device_key)
|
||||
for description in ENTITIES
|
||||
for device_key in new_devices_set
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
|
||||
_async_add_new_devices()
|
||||
|
||||
|
||||
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aten_pe",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["atenpdu==0.3.2"]
|
||||
"requirements": ["atenpdu==0.3.6"]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from . import get_maybe_authenticated_session
|
||||
@@ -75,6 +76,21 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={"address": f"{host}:{port}"},
|
||||
)
|
||||
|
||||
async def _async_box_from_host_or_abort(
|
||||
self, api_host: ApiHost
|
||||
) -> Box | ConfigFlowResult:
|
||||
"""Try to connect to the device; return product or an abort result."""
|
||||
try:
|
||||
return await Box.async_from_host(api_host)
|
||||
except UnsupportedBoxVersion:
|
||||
return self.async_abort(reason="unsupported_device_version")
|
||||
except UnsupportedBoxResponse:
|
||||
return self.async_abort(reason="unsupported_device_response")
|
||||
except UnauthorizedRequest:
|
||||
return self.async_abort(reason="authorization_required")
|
||||
except Error:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
async def _async_from_host_or_form(
|
||||
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
|
||||
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
|
||||
@@ -101,45 +117,50 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery."""
|
||||
hass = self.hass
|
||||
ipaddress = (discovery_info.host, discovery_info.port)
|
||||
self.device_config["host"] = discovery_info.host
|
||||
self.device_config["port"] = discovery_info.port
|
||||
|
||||
websession = async_get_clientsession(hass)
|
||||
async def _async_handle_discovery(self, host: str, port: int) -> ConfigFlowResult:
|
||||
"""Handle discovery by IP and port; probe device then confirm with the user."""
|
||||
self.device_config["host"] = host
|
||||
self.device_config["port"] = port
|
||||
|
||||
websession = async_get_clientsession(self.hass)
|
||||
api_host = ApiHost(
|
||||
*ipaddress, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
|
||||
)
|
||||
|
||||
try:
|
||||
product = await Box.async_from_host(api_host)
|
||||
except UnauthorizedRequest:
|
||||
return self.async_abort(reason="authorization_required")
|
||||
except UnsupportedBoxVersion:
|
||||
return self.async_abort(reason="unsupported_device_version")
|
||||
except UnsupportedBoxResponse:
|
||||
return self.async_abort(reason="unsupported_device_response")
|
||||
result = await self._async_box_from_host_or_abort(api_host)
|
||||
if not isinstance(result, Box):
|
||||
return result
|
||||
product = result
|
||||
|
||||
self.device_config["name"] = product.name
|
||||
# Check if configured but IP changed since
|
||||
await self.async_set_unique_id(product.unique_id)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {
|
||||
"name": self.device_config["name"],
|
||||
"host": self.device_config["host"],
|
||||
"host": host,
|
||||
},
|
||||
"configuration_url": f"http://{discovery_info.host}",
|
||||
"configuration_url": f"http://{host}",
|
||||
}
|
||||
)
|
||||
return await self.async_step_confirm_discovery()
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle DHCP discovery."""
|
||||
return await self._async_handle_discovery(discovery_info.ip, DEFAULT_PORT)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery."""
|
||||
return await self._async_handle_discovery(
|
||||
discovery_info.host, discovery_info.port or DEFAULT_PORT
|
||||
)
|
||||
|
||||
async def async_step_confirm_discovery(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -158,7 +179,6 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={
|
||||
"name": self.device_config["name"],
|
||||
"host": self.device_config["host"],
|
||||
"port": self.device_config["port"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,45 @@
|
||||
"name": "BleBox devices",
|
||||
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{ "hostname": "rollergate*" },
|
||||
{ "hostname": "gatebox*" },
|
||||
{ "hostname": "doorbox*" },
|
||||
{ "hostname": "shutterbox*" },
|
||||
{ "hostname": "switchbox*" },
|
||||
{ "hostname": "dimmerbox*" },
|
||||
{ "hostname": "dacbox*" },
|
||||
{ "hostname": "wlightbox*" },
|
||||
{ "hostname": "pixelbox*" },
|
||||
{ "hostname": "saunabox*" },
|
||||
{ "hostname": "thermobox*" },
|
||||
{ "hostname": "tempsensor*" },
|
||||
{ "hostname": "energymeter*" },
|
||||
{ "hostname": "airsensor*" },
|
||||
{ "hostname": "humiditysensor*" },
|
||||
{ "hostname": "rainsensor*" },
|
||||
{ "hostname": "floodsensor*" },
|
||||
{ "hostname": "luxsensor*" },
|
||||
{ "hostname": "inputsensor*" },
|
||||
{ "hostname": "opensensor*" },
|
||||
{ "hostname": "windsensor*" },
|
||||
{ "hostname": "co2sensor*" },
|
||||
{ "hostname": "simongo*" },
|
||||
{ "hostname": "sabaj-k-smrt*" },
|
||||
{ "hostname": "rico*" },
|
||||
{ "hostname": "smartrollergate*" },
|
||||
{ "hostname": "darco_ero_32ws_0*" },
|
||||
{ "hostname": "pergoladc*" },
|
||||
{ "hostname": "seltsmartscreen*" },
|
||||
{ "hostname": "seltvenetianblind*" },
|
||||
{ "hostname": "doorunitbox*" },
|
||||
{ "hostname": "drutexsmart*" },
|
||||
{ "hostname": "swingatecontroller*" },
|
||||
{ "hostname": "windowopener*" },
|
||||
{ "hostname": "smartawning*" },
|
||||
{ "hostname": "smartshade*" },
|
||||
{ "hostname": "smartshutter*" }
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/blebox",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"address_already_configured": "A BleBox device is already configured at {address}.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"authorization_required": "The BleBox device requires authentication.",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device identifier does not match the previously configured device.",
|
||||
@@ -18,6 +19,10 @@
|
||||
},
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"confirm_discovery": {
|
||||
"description": "Do you want to add the BleBox device **{name}** at `{host}` to Home Assistant?",
|
||||
"title": "BleBox device discovered"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -105,7 +105,7 @@ class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity):
|
||||
if port == DEFAULT_PORT:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, format_mac(sync_status.mac))},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
|
||||
connections={(CONNECTION_NETWORK_MAC, sync_status.mac)},
|
||||
name=sync_status.name,
|
||||
manufacturer=sync_status.brand,
|
||||
model=sync_status.model_name,
|
||||
|
||||
@@ -118,7 +118,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
if port == DEFAULT_PORT:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, format_mac(sync_status.mac))},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
|
||||
connections={(CONNECTION_NETWORK_MAC, sync_status.mac)},
|
||||
name=sync_status.name,
|
||||
manufacturer=sync_status.brand,
|
||||
model=sync_status.model_name,
|
||||
|
||||
@@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> boo
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(shc_info.unique_id))},
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, shc_info.unique_id)},
|
||||
identifiers={(DOMAIN, shc_info.unique_id)},
|
||||
manufacturer="Bosch",
|
||||
name=entry.title,
|
||||
|
||||
@@ -123,7 +123,14 @@ class _BrandsBaseView(HomeAssistantView):
|
||||
)
|
||||
if not authenticated:
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
async def _serve_from_custom_integration(
|
||||
|
||||
@@ -118,7 +118,14 @@ class BroadlinkDevice[_ApiT: blk.Device = blk.Device]:
|
||||
return False
|
||||
|
||||
except (NetworkTimeoutError, OSError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connect_failed",
|
||||
translation_placeholders={
|
||||
"host": api.host[0],
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
except BroadlinkException as err:
|
||||
_LOGGER.error(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "broadlink",
|
||||
"name": "Broadlink",
|
||||
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"],
|
||||
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connect_failed": {
|
||||
"message": "Failed to connect to the device at {host}: {error}"
|
||||
},
|
||||
"frequency_not_supported": {
|
||||
"message": "Broadlink devices cannot transmit on {frequency} MHz"
|
||||
},
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bryant_evolution",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["evolutionhttp==0.0.18"]
|
||||
"requirements": ["evolutionhttp==0.0.19"]
|
||||
}
|
||||
|
||||
@@ -31,11 +31,7 @@ from homeassistant.exceptions import (
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
@@ -75,7 +71,7 @@ def get_bsblan_device_info(
|
||||
"""Build DeviceInfo for the main BSB-LAN controller device."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, device.MAC)},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))},
|
||||
connections={(CONNECTION_NETWORK_MAC, device.MAC)},
|
||||
name=device.name,
|
||||
manufacturer="BSBLAN Inc.",
|
||||
model=(
|
||||
|
||||
@@ -460,7 +460,7 @@ class EventTrigger(Trigger):
|
||||
listener = TargetCalendarEventListener(
|
||||
self._hass, target_selection, self._event_type, offset, run_action
|
||||
)
|
||||
return listener.async_setup()
|
||||
return await listener.async_setup()
|
||||
|
||||
|
||||
class EventStartedTrigger(EventTrigger):
|
||||
|
||||
@@ -785,7 +785,9 @@ class CameraView(HomeAssistantView):
|
||||
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
|
||||
raise (
|
||||
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
|
||||
)
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
@@ -793,11 +795,15 @@ class CameraView(HomeAssistantView):
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or camera access token
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
if not camera.is_on:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from pycasperglow import CasperGlow
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -27,7 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"address": address},
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass, address.upper(), BluetoothReachabilityIntent.CONNECTION
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
glow = CasperGlow(ble_device)
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"message": "An error occurred while communicating with the Casper Glow: {error}"
|
||||
},
|
||||
"device_not_found": {
|
||||
"message": "Could not find Casper Glow device with address {address}"
|
||||
"message": "Could not find Casper Glow device with address {address}: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ async def async_setup_entry(
|
||||
class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
|
||||
"""Climate device for CCM15 coordinator."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_has_entity_name = True
|
||||
_attr_target_temperature_step = PRECISION_WHOLE
|
||||
_attr_hvac_modes = [
|
||||
@@ -93,6 +92,13 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
|
||||
"""Return device data."""
|
||||
return self.coordinator.get_ac_data(self._ac_index)
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement reported by the device."""
|
||||
if (data := self.data) is not None and not data.is_celsius:
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int | None:
|
||||
"""Return current temperature."""
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
from cieloconnectapi.device import CieloDeviceAPI
|
||||
from cieloconnectapi.model import CieloDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -69,7 +65,7 @@ class CieloDeviceEntity(CieloBaseEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
|
||||
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
|
||||
manufacturer="Cielo",
|
||||
configuration_url="https://home.cielowigle.com/",
|
||||
suggested_area=device.name,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["webexpythonsdk"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["webexpythonsdk==2.0.1"]
|
||||
"requirements": ["webexpythonsdk==2.0.6"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
|
||||
from aiohttp import ClientConnectionError
|
||||
from pydaikin.daikin_base import Appliance
|
||||
from pydaikin.exceptions import DaikinException
|
||||
from pydaikin.factory import DaikinFactory
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -56,6 +57,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
|
||||
except ClientConnectionError as err:
|
||||
_LOGGER.debug("ClientConnectionError to %s", host)
|
||||
raise ConfigEntryNotReady from err
|
||||
except DaikinException as err:
|
||||
# pydaikin has no subclass hierarchy for transient vs permanent errors.
|
||||
# DaikinException during factory/init almost always means the device is not
|
||||
# yet ready (e.g. "Empty values." when the unit hasn't finished booting),
|
||||
# so treat all factory-time DaikinExceptions as transient.
|
||||
_LOGGER.debug("DaikinException from %s: %s", host, err)
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
coordinator = DaikinCoordinator(hass, entry, device)
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from data_grand_lyon_ha import DataGrandLyonClient, TclStop, find_tcl_stop_by_id
|
||||
from data_grand_lyon_ha import (
|
||||
DataGrandLyonClient,
|
||||
TclStop,
|
||||
VelovStation,
|
||||
find_tcl_stop_by_id,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -49,12 +54,6 @@ STEP_RECONFIGURE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_VELOV_STATION_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_STATION_ID): vol.Coerce(int),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Data Grand Lyon."""
|
||||
@@ -302,27 +301,96 @@ def _stop_label(stop: TclStop) -> str:
|
||||
class VelovStationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle a subentry flow for adding a Vélo'v station."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the flow."""
|
||||
self._stations: list[VelovStation] = []
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the user step to add a new Vélo'v station."""
|
||||
entry = self._get_entry()
|
||||
"""Pick a station from the list fetched from the API, or enter one manually."""
|
||||
if not self._stations:
|
||||
if error := await self._async_load_stations():
|
||||
return self.async_abort(reason=error)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
station_id = user_input[CONF_STATION_ID]
|
||||
unique_id = f"velov_{station_id}"
|
||||
try:
|
||||
station_id = int(user_input[CONF_STATION_ID])
|
||||
except ValueError:
|
||||
errors[CONF_STATION_ID] = "invalid_station_id"
|
||||
else:
|
||||
entry = self._get_entry()
|
||||
unique_id = f"velov_{station_id}"
|
||||
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"Vélo'v {station_id}",
|
||||
data={CONF_STATION_ID: station_id},
|
||||
unique_id=unique_id,
|
||||
return self.async_create_entry(
|
||||
title=f"Vélo'v {station_id}",
|
||||
data={CONF_STATION_ID: station_id},
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
options = [
|
||||
SelectOptionDict(
|
||||
value=str(station.number), label=_velov_station_label(station)
|
||||
)
|
||||
|
||||
for station in sorted(
|
||||
self._stations,
|
||||
key=lambda s: (s.name, s.commune or "", s.number or 0),
|
||||
)
|
||||
]
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_STATION_ID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
sort=False,
|
||||
custom_value=True,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_VELOV_STATION_DATA_SCHEMA,
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_load_stations(self) -> str | None:
|
||||
"""Fetch Vélo'v stations from the API, returning an error key on failure."""
|
||||
entry = self._get_entry()
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = DataGrandLyonClient(
|
||||
session=session,
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
self._stations = await client.get_velov_stations()
|
||||
except ClientResponseError as err:
|
||||
if err.status in (401, 403):
|
||||
return "invalid_auth"
|
||||
return "cannot_connect"
|
||||
except ClientError, TimeoutError:
|
||||
return "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected error fetching Data Grand Lyon Vélo'v stations"
|
||||
)
|
||||
return "unknown"
|
||||
return None
|
||||
|
||||
|
||||
def _velov_station_label(station: VelovStation) -> str:
|
||||
label = station.name
|
||||
if station.address or station.commune:
|
||||
label += (
|
||||
" (" + ", ".join(filter(None, [station.address, station.commune])) + ")"
|
||||
)
|
||||
label += f" - {station.number}"
|
||||
|
||||
return label
|
||||
|
||||
@@ -76,16 +76,25 @@
|
||||
},
|
||||
"velov_station": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"entry_type": "Vélo'v station",
|
||||
"error": {
|
||||
"invalid_station_id": "Station ID must be a number."
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add Vélo'v station"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"station_id": "Station ID"
|
||||
"station_id": "Station"
|
||||
},
|
||||
"data_description": {
|
||||
"station_id": "Search by station name, address or city, or enter a station ID directly."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["doorbirdpy"],
|
||||
"requirements": ["DoorBirdPy==3.0.11"],
|
||||
"requirements": ["DoorBirdPy==3.0.12"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pdunehd"],
|
||||
"requirements": ["pdunehd==1.3.2"]
|
||||
"requirements": ["pdunehd==1.3.3"]
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Please enter the API key obtained from ecobee.com."
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"""Base entity for the Elgato integration."""
|
||||
|
||||
from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -33,6 +29,4 @@ class ElgatoEntity(CoordinatorEntity[ElgatoDataUpdateCoordinator]):
|
||||
hw_version=str(coordinator.data.info.hardware_board_type),
|
||||
)
|
||||
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
|
||||
self._attr_device_info[ATTR_CONNECTIONS] = {
|
||||
(CONNECTION_NETWORK_MAC, format_mac(mac))
|
||||
}
|
||||
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)}
|
||||
|
||||
@@ -93,7 +93,7 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]):
|
||||
if "mac" in iface and iface["mac"] is not None
|
||||
}
|
||||
self.device_info[ATTR_CONNECTIONS] = {
|
||||
(CONNECTION_NETWORK_MAC, format_mac(iface["mac"]))
|
||||
(CONNECTION_NETWORK_MAC, iface["mac"])
|
||||
for iface in about["info"]["ifaces"]
|
||||
if "mac" in iface and iface["mac"] is not None
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
"domain": "eufylife_ble",
|
||||
"name": "EufyLife",
|
||||
"bluetooth": [
|
||||
{
|
||||
"local_name": "eufy T9120"
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9130"
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9140"
|
||||
},
|
||||
@@ -16,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9149"
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9150"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@bdr99"],
|
||||
@@ -24,5 +33,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/eufylife_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["eufylife-ble-client==0.1.8"]
|
||||
"requirements": ["eufylife-ble-client==0.1.10"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyfireservicerota"],
|
||||
"requirements": ["pyfireservicerota==0.0.46"]
|
||||
"requirements": ["pyfireservicerota==0.0.49"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Config flow for Fluss+ integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from fluss_api import (
|
||||
@@ -22,6 +23,21 @@ STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): cv.string})
|
||||
class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Fluss+."""
|
||||
|
||||
async def _validate_api_key(self, api_key: str) -> dict[str, str]:
|
||||
"""Validate the API key and return any errors."""
|
||||
errors: dict[str, str] = {}
|
||||
client = FlussApiClient(api_key, session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
await client.async_get_devices()
|
||||
except FlussApiClientCommunicationError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except FlussApiClientAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception occurred")
|
||||
errors["base"] = "unknown"
|
||||
return errors
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -31,18 +47,7 @@ class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
api_key = user_input[CONF_API_KEY]
|
||||
self._async_abort_entries_match({CONF_API_KEY: api_key})
|
||||
client = FlussApiClient(
|
||||
user_input[CONF_API_KEY], session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
await client.async_get_devices()
|
||||
except FlussApiClientCommunicationError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except FlussApiClientAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception occurred")
|
||||
errors["base"] = "unknown"
|
||||
errors = await self._validate_api_key(api_key)
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title="My Fluss+ Devices", data=user_input
|
||||
@@ -51,3 +56,30 @@ class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication when the API key is no longer valid."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm re-authentication with a new API key."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
api_key = user_input[CONF_API_KEY]
|
||||
self._async_abort_entries_match({CONF_API_KEY: api_key})
|
||||
errors = await self._validate_api_key(api_key)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_KEY: api_key},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from fluss_api import (
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -60,7 +60,7 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
|
||||
try:
|
||||
devices = await self.api.async_get_devices()
|
||||
except FlussApiClientAuthenticationError as err:
|
||||
raise ConfigEntryError(f"Authentication failed: {err}") from err
|
||||
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
|
||||
except FlussApiClientError as err:
|
||||
raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
entity-translations: done
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -9,6 +10,15 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "The API key found in the profile page of the Fluss+ app."
|
||||
},
|
||||
"description": "The Fluss+ API key is no longer valid. Get your API key from the profile page of the Fluss+ app, or generate a new one, and enter it below."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["foobot_async"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["foobot_async==1.0.0"]
|
||||
"requirements": ["foobot_async==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -65,14 +65,16 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
_LOGGER.debug("Discovered device: %s", discovery_info)
|
||||
|
||||
await self.async_set_unique_id(discovery_info.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
mfg = await async_get_product(self.hass, discovery_info.address)
|
||||
self.devices[discovery_info.address] = mfg
|
||||
if mfg.product_type not in _SUPPORTED_PRODUCT_TYPES:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
self.address = discovery_info.address
|
||||
await self.async_set_unique_id(self.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
|
||||
@@ -230,11 +230,19 @@ class GoogleGenerativeAISttEntity(
|
||||
f"audio/L{metadata.bit_rate.value};rate={metadata.sample_rate.value}",
|
||||
)
|
||||
|
||||
prompt = self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT)
|
||||
if metadata.language:
|
||||
prompt = (
|
||||
f"{prompt}\n"
|
||||
f"The spoken language is {metadata.language}. "
|
||||
f"Transcribe in that language."
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._genai_client.aio.models.generate_content(
|
||||
model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL),
|
||||
contents=[
|
||||
self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT),
|
||||
prompt,
|
||||
Part.from_bytes(
|
||||
data=audio_data,
|
||||
mime_type=f"audio/{metadata.format.value}",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/gree",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["greeclimate"],
|
||||
"requirements": ["greeclimate==2.1.1"]
|
||||
"requirements": ["greeclimate==2.1.4"]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,132 @@
|
||||
"""Green Planet Energy integration for Home Assistant."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GreenPlanetEnergyUpdateCoordinator
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type GreenPlanetEnergyConfigEntry = ConfigEntry[GreenPlanetEnergyUpdateCoordinator]
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
# Service constants
|
||||
SERVICE_GET_CHEAPEST_DURATION = "get_cheapest_duration"
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_TIME_RANGE = "time_range"
|
||||
|
||||
# Time range options
|
||||
TIME_RANGE_DAY = "day"
|
||||
TIME_RANGE_NIGHT = "night"
|
||||
TIME_RANGE_FULL_DAY = "full_day"
|
||||
|
||||
SERVICE_GET_CHEAPEST_DURATION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DURATION): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=0.5, max=24)
|
||||
),
|
||||
vol.Optional(ATTR_TIME_RANGE, default=TIME_RANGE_FULL_DAY): vol.In(
|
||||
[TIME_RANGE_DAY, TIME_RANGE_NIGHT, TIME_RANGE_FULL_DAY]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Green Planet Energy component."""
|
||||
|
||||
async def get_cheapest_duration(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the get_cheapest_duration service call."""
|
||||
# This integration has single_config_entry, so get the first entry
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
if not entries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry",
|
||||
)
|
||||
|
||||
entry = entries[0]
|
||||
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
)
|
||||
|
||||
coordinator: GreenPlanetEnergyUpdateCoordinator = entry.runtime_data
|
||||
|
||||
duration = call.data[ATTR_DURATION]
|
||||
time_range = call.data[ATTR_TIME_RANGE]
|
||||
data = coordinator.data
|
||||
api = coordinator.api
|
||||
now = dt_util.now()
|
||||
current_hour = now.hour
|
||||
|
||||
result: tuple[float | None, int | None]
|
||||
|
||||
if time_range == TIME_RANGE_DAY:
|
||||
result = api.get_cheapest_duration_day(data, duration, current_hour)
|
||||
elif time_range == TIME_RANGE_NIGHT:
|
||||
result = api.get_cheapest_duration_night(data, duration, current_hour)
|
||||
else: # TIME_RANGE_FULL_DAY
|
||||
result = api.get_cheapest_duration(data, duration, current_hour)
|
||||
|
||||
avg_price, start_hour_result = result
|
||||
|
||||
if avg_price is None or start_hour_result is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_data_available",
|
||||
)
|
||||
|
||||
start_time = dt_util.start_of_local_day(now).replace(
|
||||
hour=start_hour_result, minute=0, second=0, microsecond=0
|
||||
)
|
||||
|
||||
# If the calculated start time is in the past, shift to tomorrow
|
||||
if start_time < now:
|
||||
start_time = start_time + timedelta(days=1)
|
||||
|
||||
end_time = start_time + timedelta(hours=duration)
|
||||
|
||||
hours_until_start = (start_time - now).total_seconds() / 3600
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
"average_price": round(avg_price / 100, 4),
|
||||
"start_time": start_time.isoformat(),
|
||||
"end_time": end_time.isoformat(),
|
||||
"hours_until_start": round(hours_until_start, 1),
|
||||
"time_range": time_range,
|
||||
}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_CHEAPEST_DURATION,
|
||||
get_cheapest_duration,
|
||||
schema=SERVICE_GET_CHEAPEST_DURATION_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GreenPlanetEnergyConfigEntry
|
||||
@@ -21,6 +138,7 @@ async def async_setup_entry(
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"services": {
|
||||
"get_cheapest_duration": {
|
||||
"service": "mdi:clock-check"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Describes the format for available Green Planet Energy services
|
||||
|
||||
get_cheapest_duration:
|
||||
fields:
|
||||
duration:
|
||||
required: true
|
||||
example: 2.5
|
||||
selector:
|
||||
number:
|
||||
min: 0.5
|
||||
max: 24
|
||||
step: 0.25
|
||||
unit_of_measurement: "h"
|
||||
time_range:
|
||||
required: false
|
||||
default: "full_day"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- label: Full day (00:00-24:00)
|
||||
value: "full_day"
|
||||
- label: Day (06:00-18:00)
|
||||
value: "day"
|
||||
- label: Night (18:00-06:00)
|
||||
value: "night"
|
||||
@@ -43,8 +43,33 @@
|
||||
"api_error": {
|
||||
"message": "API error: {error}"
|
||||
},
|
||||
"config_entry_not_loaded": {
|
||||
"message": "This integration instance is not currently loaded"
|
||||
},
|
||||
"connection_error": {
|
||||
"message": "Connection error: {error}"
|
||||
},
|
||||
"no_config_entry": {
|
||||
"message": "No matching integration instance was found"
|
||||
},
|
||||
"no_data_available": {
|
||||
"message": "No price data available for the requested duration and time range"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_cheapest_duration": {
|
||||
"description": "Retrieve electricity price data and find the cheapest consecutive time window for a given duration.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"description": "Duration in hours for which to find the cheapest time window.",
|
||||
"name": "Duration"
|
||||
},
|
||||
"time_range": {
|
||||
"description": "Time range to search within.",
|
||||
"name": "Time range"
|
||||
}
|
||||
},
|
||||
"name": "Get cheapest duration"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Home Assistant integration for Greencell EVSE devices."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
import logging
|
||||
|
||||
from greencell_client.access import GreencellAccess, GreencellHaAccessLevel
|
||||
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.mqtt import ReceiveMessage
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
|
||||
from .const import CONF_SERIAL_NUMBER, DISCOVERY_TIMEOUT, GREENCELL_DISC_TOPIC
|
||||
from .models import GreencellConfigEntry, GreencellRuntimeData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
def make_ready_handler(
|
||||
serial: str, event: asyncio.Event
|
||||
) -> Callable[[ReceiveMessage], None]:
|
||||
"""Create an MQTT message handler that sets event when device matches serial."""
|
||||
|
||||
@callback
|
||||
def _on_message(message: ReceiveMessage) -> None:
|
||||
if event.is_set():
|
||||
return
|
||||
try:
|
||||
data = json.loads(message.payload)
|
||||
except ValueError, TypeError:
|
||||
return
|
||||
|
||||
if message.topic == GREENCELL_DISC_TOPIC:
|
||||
if data.get("id") != serial:
|
||||
return
|
||||
elif data.get("id") and data["id"] != serial:
|
||||
return
|
||||
|
||||
event.set()
|
||||
|
||||
return _on_message
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GreencellConfigEntry) -> bool:
|
||||
"""Set up Greencell from a config entry."""
|
||||
|
||||
if not await mqtt.async_wait_for_mqtt_client(hass):
|
||||
raise ConfigEntryNotReady("MQTT integration is not available")
|
||||
|
||||
serial: str = entry.data[CONF_SERIAL_NUMBER]
|
||||
device_ready_event = asyncio.Event()
|
||||
on_message = make_ready_handler(serial, device_ready_event)
|
||||
|
||||
try:
|
||||
unsub_disc = await mqtt.async_subscribe(hass, GREENCELL_DISC_TOPIC, on_message)
|
||||
unsub_volt = await mqtt.async_subscribe(
|
||||
hass, f"/greencell/evse/{serial}/voltage", on_message
|
||||
)
|
||||
try:
|
||||
async with asyncio.timeout(DISCOVERY_TIMEOUT):
|
||||
await device_ready_event.wait()
|
||||
finally:
|
||||
unsub_disc()
|
||||
unsub_volt()
|
||||
except TimeoutError as err:
|
||||
raise ConfigEntryNotReady(f"No initial data from device {serial}") from err
|
||||
except HomeAssistantError as err:
|
||||
raise ConfigEntryNotReady(f"MQTT error: {err}") from err
|
||||
|
||||
entry.runtime_data = GreencellRuntimeData(
|
||||
access=GreencellAccess(GreencellHaAccessLevel.EXECUTE),
|
||||
current_data=ElecData3Phase(),
|
||||
voltage_data=ElecData3Phase(),
|
||||
power_data=ElecDataSinglePhase(),
|
||||
state_data=ElecDataSinglePhase(),
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: GreencellConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Config flow for Greencell EVSE integration in Home Assistant."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from greencell_client.utils import GreencellUtils
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.mqtt import ReceiveMessage
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
|
||||
|
||||
from . import const
|
||||
from .const import (
|
||||
CONF_SERIAL_NUMBER,
|
||||
DOMAIN,
|
||||
GREENCELL_BROADCAST_TOPIC,
|
||||
GREENCELL_DISC_TOPIC,
|
||||
GREENCELL_HABU_DEN,
|
||||
GREENCELL_OTHER_DEVICE,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EVSEConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Greencell EVSE devices."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovered: dict[str, dict[str, Any]] = {}
|
||||
self._discovered_serial: str | None = None
|
||||
self._discovery_event: asyncio.Event | None = None
|
||||
self._remove_listener: Callable | None = None
|
||||
|
||||
def _get_device_name(self, serial: str) -> str:
|
||||
"""Determine the device name based on the serial number."""
|
||||
return (
|
||||
GREENCELL_HABU_DEN
|
||||
if GreencellUtils.device_is_habu_den(serial)
|
||||
else GREENCELL_OTHER_DEVICE
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_mqtt_message_received(self, msg: ReceiveMessage) -> None:
|
||||
"""Handle incoming MQTT messages on the discovery topic."""
|
||||
try:
|
||||
payload = json.loads(msg.payload)
|
||||
except json.JSONDecodeError, AttributeError:
|
||||
return
|
||||
|
||||
serial = payload.get("id")
|
||||
if isinstance(serial, str) and serial.strip():
|
||||
self._discovered[serial] = payload
|
||||
if self._discovery_event:
|
||||
self._discovery_event.set()
|
||||
|
||||
async def async_step_mqtt(
|
||||
self, discovery_info: MqttServiceInfo
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Handle a flow initialized by MQTT discovery."""
|
||||
try:
|
||||
payload = json.loads(discovery_info.payload)
|
||||
serial = payload.get("id")
|
||||
except json.JSONDecodeError, AttributeError:
|
||||
return self.async_abort(reason="invalid_discovery_data")
|
||||
|
||||
if not isinstance(serial, str) or not serial.strip():
|
||||
return self.async_abort(reason="invalid_discovery_data")
|
||||
|
||||
await self.async_set_unique_id(serial)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self._discovered_serial = serial
|
||||
device_name = self._get_device_name(serial)
|
||||
self.context.update({"title_placeholders": {"name": f"{device_name} {serial}"}})
|
||||
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Confirm addition of a discovered device."""
|
||||
assert self._discovered_serial is not None
|
||||
serial = self._discovered_serial
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=f"{self._get_device_name(serial)} {serial}",
|
||||
data={CONF_SERIAL_NUMBER: serial},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
description_placeholders={"serial": serial},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Manual step: start active discovery process."""
|
||||
try:
|
||||
if not mqtt.is_connected(self.hass):
|
||||
return self.async_abort(reason="mqtt_not_connected")
|
||||
except KeyError:
|
||||
return self.async_abort(reason="mqtt_not_configured")
|
||||
|
||||
return await self.async_step_discover()
|
||||
|
||||
async def async_step_discover(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Discovery step: subscribe, broadcast, and wait for responses."""
|
||||
self._discovery_event = asyncio.Event()
|
||||
|
||||
try:
|
||||
self._remove_listener = await mqtt.async_subscribe(
|
||||
self.hass,
|
||||
GREENCELL_DISC_TOPIC,
|
||||
self._async_mqtt_message_received,
|
||||
)
|
||||
except HomeAssistantError, ValueError:
|
||||
return self.async_abort(reason="mqtt_subscription_failed")
|
||||
|
||||
try:
|
||||
payload = json.dumps({"name": "BROADCAST"})
|
||||
await mqtt.async_publish(
|
||||
self.hass, GREENCELL_BROADCAST_TOPIC, payload, qos=0, retain=False
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._discovery_event.wait(), timeout=const.DISCOVERY_TIMEOUT
|
||||
)
|
||||
# Grace period for additional devices
|
||||
await asyncio.sleep(0.5)
|
||||
except TimeoutError:
|
||||
_LOGGER.debug("Discovery timed out waiting for device responses")
|
||||
finally:
|
||||
self._remove_listener()
|
||||
|
||||
if not self._discovered:
|
||||
return self.async_abort(reason="no_discovery_data")
|
||||
|
||||
if len(self._discovered) == 1:
|
||||
serial = next(iter(self._discovered))
|
||||
return await self._async_create_entry(serial)
|
||||
|
||||
return await self.async_step_select()
|
||||
|
||||
async def async_step_select(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Let the user select one of the discovered devices."""
|
||||
if user_input is not None:
|
||||
serial = user_input[CONF_SERIAL_NUMBER]
|
||||
return await self._async_create_entry(serial)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="select",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SERIAL_NUMBER): vol.In(
|
||||
list(self._discovered.keys())
|
||||
)
|
||||
}
|
||||
),
|
||||
description_placeholders={"count": str(len(self._discovered))},
|
||||
)
|
||||
|
||||
async def _async_create_entry(self, serial: str) -> config_entries.ConfigFlowResult:
|
||||
"""Finalize entry creation for selected device."""
|
||||
await self.async_set_unique_id(serial, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
device_name = self._get_device_name(serial)
|
||||
title = f"{device_name} {serial}"
|
||||
|
||||
_LOGGER.info("Discovered and added device: %s", title)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data={CONF_SERIAL_NUMBER: serial},
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Core constants for the Greencell EVSE Home Assistant integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
# Greencell constants
|
||||
|
||||
DOMAIN = "greencell"
|
||||
MANUFACTURER: Final = "Greencell"
|
||||
|
||||
# Maximal current configuration
|
||||
|
||||
DEFAULT_MIN_CURRENT = 6
|
||||
DEFAULT_MAX_CURRENT_OTHER = 16
|
||||
DEFAULT_MAX_CURRENT_HABU_DEN = 32
|
||||
|
||||
# Topics
|
||||
|
||||
GREENCELL_BROADCAST_TOPIC = "/greencell/broadcast"
|
||||
GREENCELL_DISC_TOPIC = "/greencell/broadcast/device"
|
||||
|
||||
# Device names
|
||||
|
||||
GREENCELL_HABU_DEN = "Habu Den"
|
||||
GREENCELL_OTHER_DEVICE = "Greencell Device"
|
||||
|
||||
# Other constants
|
||||
|
||||
DISCOVERY_MIN_TIMEOUT = 5.0
|
||||
DISCOVERY_TIMEOUT = 30.0
|
||||
SET_CURRENT_RETRY_TIME = 15
|
||||
CONF_SERIAL_NUMBER = "serial_number"
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"current_l1": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"current_l2": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"current_l3": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"power": {
|
||||
"default": "mdi:battery-charging-high"
|
||||
},
|
||||
"status": {
|
||||
"default": "mdi:ev-plug-type2"
|
||||
},
|
||||
"voltage_l1": {
|
||||
"default": "mdi:meter-electric"
|
||||
},
|
||||
"voltage_l2": {
|
||||
"default": "mdi:meter-electric"
|
||||
},
|
||||
"voltage_l3": {
|
||||
"default": "mdi:meter-electric"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "greencell",
|
||||
"name": "Greencell",
|
||||
"codeowners": ["@BrzezowskiGC"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["mqtt"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/greencell",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"mqtt": ["/greencell/broadcast/device"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["greencell_client==1.0.3"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Type definitions for Greencell integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from greencell_client.access import GreencellAccess
|
||||
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
|
||||
@dataclass
|
||||
class GreencellRuntimeData:
|
||||
"""Runtime data for Greencell integration."""
|
||||
|
||||
access: GreencellAccess
|
||||
current_data: ElecData3Phase
|
||||
voltage_data: ElecData3Phase
|
||||
power_data: ElecDataSinglePhase
|
||||
state_data: ElecDataSinglePhase
|
||||
|
||||
|
||||
type GreencellConfigEntry = ConfigEntry[GreencellRuntimeData]
|
||||
@@ -0,0 +1,63 @@
|
||||
rules:
|
||||
# Bronze
|
||||
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions or services.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
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: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Home Assistant integration module for Greencell EVSE sensor entities over MQTT."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from greencell_client.access import GreencellAccess
|
||||
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
|
||||
from greencell_client.mqtt_parser import MqttParser
|
||||
from greencell_client.utils import GreencellUtils
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.mqtt import ReceiveMessage
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfPower,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import (
|
||||
CONF_SERIAL_NUMBER,
|
||||
DOMAIN,
|
||||
GREENCELL_HABU_DEN,
|
||||
GREENCELL_OTHER_DEVICE,
|
||||
MANUFACTURER,
|
||||
)
|
||||
from .models import GreencellConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GreencellSensorDescription(SensorEntityDescription):
|
||||
"""Describe a Greencell sensor."""
|
||||
|
||||
value_fn: Callable[[Any], StateType]
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS = (
|
||||
GreencellSensorDescription(
|
||||
key="current_l1",
|
||||
translation_key="current_l1",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
value_fn=lambda data: data / 1000,
|
||||
),
|
||||
GreencellSensorDescription(
|
||||
key="current_l2",
|
||||
translation_key="current_l2",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
value_fn=lambda data: data / 1000,
|
||||
),
|
||||
GreencellSensorDescription(
|
||||
key="current_l3",
|
||||
translation_key="current_l3",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
value_fn=lambda data: data / 1000,
|
||||
),
|
||||
GreencellSensorDescription(
|
||||
key="voltage_l1",
|
||||
translation_key="voltage_l1",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda data: data,
|
||||
),
|
||||
GreencellSensorDescription(
|
||||
key="voltage_l2",
|
||||
translation_key="voltage_l2",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda data: data,
|
||||
),
|
||||
GreencellSensorDescription(
|
||||
key="voltage_l3",
|
||||
translation_key="voltage_l3",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda data: data,
|
||||
),
|
||||
GreencellSensorDescription(
|
||||
key="power",
|
||||
translation_key="power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data,
|
||||
),
|
||||
GreencellSensorDescription(
|
||||
key="status",
|
||||
translation_key="status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"idle",
|
||||
"connected",
|
||||
"waiting_for_car",
|
||||
"charging",
|
||||
"finished",
|
||||
"error_car",
|
||||
"error_evse",
|
||||
],
|
||||
value_fn=lambda data: str(data).lower() if isinstance(data, str) else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --- Config Flow Setup ---
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GreencellConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Greencell EVSE sensors from a config entry."""
|
||||
|
||||
serial_number: str = entry.data[CONF_SERIAL_NUMBER]
|
||||
|
||||
mqtt_topic_current = f"/greencell/evse/{serial_number}/current"
|
||||
mqtt_topic_voltage = f"/greencell/evse/{serial_number}/voltage"
|
||||
mqtt_topic_power = f"/greencell/evse/{serial_number}/power"
|
||||
mqtt_topic_status = f"/greencell/evse/{serial_number}/status"
|
||||
mqtt_topic_device_state = f"/greencell/evse/{serial_number}/device_state"
|
||||
|
||||
desc_map = {desc.key: desc for desc in SENSOR_DESCRIPTIONS}
|
||||
|
||||
runtime = entry.runtime_data
|
||||
access = runtime.access
|
||||
current_data_obj = runtime.current_data
|
||||
voltage_data_obj = runtime.voltage_data
|
||||
power_data_obj = runtime.power_data
|
||||
state_data_obj = runtime.state_data
|
||||
|
||||
data_mapping = {
|
||||
"current": current_data_obj,
|
||||
"voltage": voltage_data_obj,
|
||||
"power": power_data_obj,
|
||||
"status": state_data_obj,
|
||||
}
|
||||
|
||||
sensors: list[HabuSensor] = [
|
||||
Habu3PhaseSensor(
|
||||
sensor_data=data_mapping[description.key.split("_")[0]],
|
||||
phase=description.key.split("_")[-1],
|
||||
sensor_type=description.key,
|
||||
serial_number=serial_number,
|
||||
access=access,
|
||||
description=description,
|
||||
)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if description.key.startswith(("current_l", "voltage_l"))
|
||||
]
|
||||
|
||||
sensors.extend(
|
||||
HabuSingleSensor(
|
||||
sensor_data=data_mapping[key],
|
||||
serial_number=serial_number,
|
||||
sensor_type=key,
|
||||
access=access,
|
||||
description=desc_map[key],
|
||||
)
|
||||
for key in ("power", "status")
|
||||
)
|
||||
|
||||
@callback
|
||||
def current_message_received(msg: ReceiveMessage) -> None:
|
||||
"""Handle the current message."""
|
||||
MqttParser.parse_3phase_msg(msg.payload, current_data_obj)
|
||||
|
||||
@callback
|
||||
def voltage_message_received(msg: ReceiveMessage) -> None:
|
||||
"""Handle the voltage message."""
|
||||
MqttParser.parse_3phase_msg(msg.payload, voltage_data_obj)
|
||||
|
||||
@callback
|
||||
def power_message_received(msg: ReceiveMessage) -> None:
|
||||
"""Handle the power message."""
|
||||
MqttParser.parse_single_phase_msg(msg.payload, "momentary", power_data_obj)
|
||||
|
||||
@callback
|
||||
def status_message_received(msg: ReceiveMessage) -> None:
|
||||
"""Handle the status message. If the device is unavailable, disable the entity."""
|
||||
|
||||
str_payload = (
|
||||
msg.payload.decode("utf-8", errors="ignore")
|
||||
if isinstance(msg.payload, (bytes, bytearray))
|
||||
else str(msg.payload)
|
||||
)
|
||||
|
||||
if "UNAVAILABLE" in str_payload or "OFFLINE" in str_payload:
|
||||
access.update("UNAVAILABLE")
|
||||
else:
|
||||
MqttParser.parse_single_phase_msg(msg.payload, "state", state_data_obj)
|
||||
|
||||
@callback
|
||||
def device_state_message_received(msg: ReceiveMessage) -> None:
|
||||
"""Handle the device state message. If device was unavailable, enable the entity."""
|
||||
access.on_msg(msg.payload)
|
||||
|
||||
try:
|
||||
for topic, handler in (
|
||||
(mqtt_topic_current, current_message_received),
|
||||
(mqtt_topic_voltage, voltage_message_received),
|
||||
(mqtt_topic_power, power_message_received),
|
||||
(mqtt_topic_status, status_message_received),
|
||||
(mqtt_topic_device_state, device_state_message_received),
|
||||
):
|
||||
unsub = await mqtt.async_subscribe(hass, topic, handler)
|
||||
if unsub is not None:
|
||||
entry.async_on_unload(unsub)
|
||||
except HomeAssistantError as err:
|
||||
raise ConfigEntryNotReady(f"MQTT is unavailable: {err}") from err
|
||||
|
||||
async_add_entities(sensors)
|
||||
|
||||
|
||||
class HabuSensor(SensorEntity):
|
||||
"""Abstract base class for Habu sensors integration."""
|
||||
|
||||
entity_description: GreencellSensorDescription
|
||||
_attr_has_entity_name = True
|
||||
_remove_listener: Callable[[], None] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sensor_type: str,
|
||||
serial_number: str,
|
||||
access: GreencellAccess,
|
||||
description: GreencellSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor entity."""
|
||||
self._sensor_type = sensor_type
|
||||
self._serial_number = serial_number
|
||||
self._access = access
|
||||
self.entity_description = description
|
||||
|
||||
self._attr_unique_id = f"{serial_number}_{description.key}"
|
||||
|
||||
if GreencellUtils.device_is_habu_den(self._serial_number):
|
||||
device_name = GREENCELL_HABU_DEN
|
||||
else:
|
||||
device_name = GREENCELL_OTHER_DEVICE
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_number)},
|
||||
name=f"{device_name} {serial_number}",
|
||||
manufacturer=MANUFACTURER,
|
||||
model=device_name,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the entity is available."""
|
||||
return not self._access.is_disabled()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register the entity with Home Assistant."""
|
||||
unsub = self._access.register_listener(self._schedule_update)
|
||||
if unsub is not None:
|
||||
self.async_on_remove(unsub)
|
||||
|
||||
def _schedule_update(self) -> None:
|
||||
"""Schedule an update for the entity."""
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
|
||||
class Habu3PhaseSensor(HabuSensor):
|
||||
"""Abstract class for 3-phase sensors (e.g. current, voltage)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sensor_data: ElecData3Phase,
|
||||
phase: str,
|
||||
sensor_type: str,
|
||||
serial_number: str,
|
||||
access: GreencellAccess,
|
||||
description: GreencellSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the 3-phase sensor."""
|
||||
super().__init__(sensor_type, serial_number, access, description)
|
||||
self._sensor_data = sensor_data
|
||||
self._phase = phase
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
raw_value = self._sensor_data.get_value(self._phase)
|
||||
if raw_value is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(raw_value)
|
||||
|
||||
|
||||
class HabuSingleSensor(HabuSensor):
|
||||
"""Example class for sensors that return a single value."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sensor_data: ElecDataSinglePhase,
|
||||
serial_number: str,
|
||||
sensor_type: str,
|
||||
access: GreencellAccess,
|
||||
description: GreencellSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the single-value sensor."""
|
||||
super().__init__(sensor_type, serial_number, access, description)
|
||||
self._value = sensor_data
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
raw_value = self._value.data
|
||||
if raw_value is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(raw_value)
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"invalid_discovery_data": "The received discovery data is invalid.",
|
||||
"mqtt_not_configured": "MQTT is not configured. Please configure MQTT first.",
|
||||
"mqtt_not_connected": "MQTT is not connected. Ensure the MQTT broker is running and configured.",
|
||||
"mqtt_subscription_failed": "Failed to subscribe to the MQTT topic for discovery.",
|
||||
"no_discovery_data": "No discovery data received. Ensure the device is online and broadcasting."
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "A Greencell device with serial number {serial} was discovered. Do you want to add it?",
|
||||
"title": "Greencell device discovered"
|
||||
},
|
||||
"select": {
|
||||
"data": {
|
||||
"serial_number": "Device serial number"
|
||||
},
|
||||
"data_description": {
|
||||
"serial_number": "Select the device you want to add to Home Assistant"
|
||||
},
|
||||
"description": "Multiple Greencell devices were found (total: {count}). Please choose which one you want to configure.",
|
||||
"title": "Select your device"
|
||||
},
|
||||
"user": {
|
||||
"description": "The integration will try to discover your EVSE devices over MQTT.",
|
||||
"title": "Set up your Greencell HabuDen EVSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"current_l1": {
|
||||
"name": "Current phase L1"
|
||||
},
|
||||
"current_l2": {
|
||||
"name": "Current phase L2"
|
||||
},
|
||||
"current_l3": {
|
||||
"name": "Current phase L3"
|
||||
},
|
||||
"power": {
|
||||
"name": "Power"
|
||||
},
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"connected": "[%key:common::state::connected%]",
|
||||
"error_car": "Car error",
|
||||
"error_evse": "EVSE error",
|
||||
"finished": "Finished",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"waiting_for_car": "Waiting for car"
|
||||
}
|
||||
},
|
||||
"voltage_l1": {
|
||||
"name": "Voltage phase L1"
|
||||
},
|
||||
"voltage_l2": {
|
||||
"name": "Voltage phase L2"
|
||||
},
|
||||
"voltage_l3": {
|
||||
"name": "Voltage phase L3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
) from err
|
||||
total_info["todayEnergy"] = total_info["today_energy"]
|
||||
total_info["totalEnergy"] = total_info["total_energy"]
|
||||
total_info["invTodayPpv"] = total_info["current_power"]
|
||||
# V1 API returns current_power in kW, convert to W
|
||||
total_info["invTodayPpv"] = total_info["current_power"] * 1000
|
||||
else:
|
||||
# Classic API: use plant_info as before.
|
||||
# Copy the response to avoid mutating the dict returned by the library
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pygtfs"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pygtfs==0.1.9"]
|
||||
"requirements": ["pygtfs==0.1.11"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["heatmiserV3"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["heatmiserV3==2.0.4"]
|
||||
"requirements": ["heatmiserV3==2.0.6"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"title": "[%key:component::here_travel_time::config::step::destination_menu::title%]"
|
||||
},
|
||||
"destination_entity_id": {
|
||||
"destination_entity": {
|
||||
"data": {
|
||||
"destination_entity_id": "Destination using an entity"
|
||||
},
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"title": "[%key:component::here_travel_time::config::step::origin_menu::title%]"
|
||||
},
|
||||
"origin_entity_id": {
|
||||
"origin_entity": {
|
||||
"data": {
|
||||
"origin_entity_id": "Origin using an entity"
|
||||
},
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhik"],
|
||||
"requirements": ["pyHik==0.4.2"]
|
||||
"requirements": ["pyHik==0.4.3"]
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool
|
||||
hub_data = devices["parent"][0]
|
||||
connections: set[tuple[str, str]] = set()
|
||||
if mac := hub_data.get("macAddress"):
|
||||
connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)))
|
||||
connections.add((dr.CONNECTION_NETWORK_MAC, mac))
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Hi-Link HLK-SW-16 device."
|
||||
|
||||
@@ -83,9 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={
|
||||
(dr.CONNECTION_NETWORK_MAC, dr.format_mac(homee.settings.mac_address))
|
||||
},
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, homee.settings.mac_address)},
|
||||
identifiers={(DOMAIN, homee.settings.uid)},
|
||||
manufacturer="homee",
|
||||
name=homee.settings.homee_name,
|
||||
|
||||
@@ -48,7 +48,7 @@ rules:
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: todo
|
||||
|
||||
@@ -177,7 +177,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Determine if the device is a homekit bridge or accessory."""
|
||||
dev_reg = dr.async_get(self.hass)
|
||||
device = dev_reg.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(hkid))}
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, hkid)}
|
||||
)
|
||||
|
||||
if device is None:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhomematic"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyhomematic==0.1.77"]
|
||||
"requirements": ["pyhomematic==0.1.78"]
|
||||
}
|
||||
|
||||
@@ -22,4 +22,7 @@ async def async_get_config_entry_diagnostics(
|
||||
anonymized = handle_config(json_state, anonymize=True)
|
||||
config = json.loads(anonymized)
|
||||
|
||||
return async_redact_data(config, TO_REDACT_CONFIG)
|
||||
return {
|
||||
"websocket": hap.websocket_diagnostics(),
|
||||
"config": async_redact_data(config, TO_REDACT_CONFIG),
|
||||
}
|
||||
|
||||
@@ -164,9 +164,11 @@ class HomematicipHAP:
|
||||
self.set_all_to_unavailable()
|
||||
elif self._ws_connection_closed.is_set():
|
||||
_LOGGER.info("HMIP access point has reconnected to the cloud")
|
||||
self._get_state_task = self.hass.async_create_task(self._try_get_state())
|
||||
self._get_state_task.add_done_callback(self.get_state_finished)
|
||||
self._ws_connection_closed.clear()
|
||||
_LOGGER.debug(
|
||||
"HMIP websocket diagnostics: %s",
|
||||
self._websocket_diagnostic_context(),
|
||||
)
|
||||
self._start_get_state_task()
|
||||
|
||||
@callback
|
||||
def async_create_entity(self, *args, **kwargs) -> None:
|
||||
@@ -180,44 +182,103 @@ class HomematicipHAP:
|
||||
await asyncio.sleep(30)
|
||||
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
||||
|
||||
def websocket_diagnostics(self) -> dict[str, Any]:
|
||||
"""Return websocket diagnostics dict (None values omitted)."""
|
||||
diagnostics = {
|
||||
"last_disconnect_reason": self.home.websocket_last_disconnect_reason(),
|
||||
"reconnect_attempts": self.home.websocket_reconnect_attempt_count(),
|
||||
"seconds_since_last_message": (
|
||||
self.home.websocket_seconds_since_last_message()
|
||||
),
|
||||
"message_count": self.home.websocket_message_count(),
|
||||
}
|
||||
return {k: v for k, v in diagnostics.items() if v is not None}
|
||||
|
||||
def _websocket_diagnostic_context(self) -> str:
|
||||
"""Return a single-line summary of websocket diagnostics for logs."""
|
||||
diagnostics = self.websocket_diagnostics()
|
||||
if not diagnostics:
|
||||
return "no diagnostics available"
|
||||
return ", ".join(f"{k}={v!r}" for k, v in diagnostics.items())
|
||||
|
||||
@callback
|
||||
def _start_get_state_task(self) -> None:
|
||||
"""Cancel any in-flight reconnect refresh and start a new one."""
|
||||
if self._get_state_task is not None and not self._get_state_task.done():
|
||||
_LOGGER.debug(
|
||||
"Cancelling previous HomematicIP reconnect state refresh task"
|
||||
)
|
||||
self._get_state_task.cancel()
|
||||
|
||||
self._get_state_task = self.hass.async_create_task(self._try_get_state())
|
||||
self._get_state_task.add_done_callback(self.get_state_finished)
|
||||
self._ws_connection_closed.clear()
|
||||
|
||||
async def _try_get_state(self) -> None:
|
||||
"""Call get_state in a loop until no error occurs.
|
||||
"""Refresh state after a websocket reconnect.
|
||||
|
||||
Uses exponential backoff on error.
|
||||
Delegates the bounded websocket wait + retry-with-exponential-backoff
|
||||
to the homematicip library (``refresh_state_after_reconnect_async``),
|
||||
and only handles HA-specific concerns here:
|
||||
- on authentication failure, trigger reauth
|
||||
- clear the per-device ``unreach`` flag and signal entity updates
|
||||
(the workaround for core#160048)
|
||||
"""
|
||||
try:
|
||||
await self.home.refresh_state_after_reconnect_async()
|
||||
except HmipAuthenticationError:
|
||||
_LOGGER.error(
|
||||
"Authentication error from HomematicIP Cloud, triggering reauth"
|
||||
)
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
return
|
||||
self._post_state_refresh()
|
||||
|
||||
# Wait until WebSocket connection is established.
|
||||
while not self.home.websocket_is_connected():
|
||||
await asyncio.sleep(2)
|
||||
async def _on_websocket_stale(self, severity: str, seconds_since: float) -> None:
|
||||
"""Log a websocket-stale event surfaced by the library.
|
||||
|
||||
delay = 8
|
||||
max_delay = 1500
|
||||
while True:
|
||||
try:
|
||||
await self.get_state()
|
||||
break
|
||||
except HmipAuthenticationError:
|
||||
_LOGGER.error(
|
||||
"Authentication error from HomematicIP Cloud, triggering reauth"
|
||||
)
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
break
|
||||
except HmipConnectionError as err:
|
||||
_LOGGER.warning(
|
||||
"Get_state failed, retrying in %s seconds: %s", delay, err
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, max_delay)
|
||||
The library polls staleness internally and fires this callback once
|
||||
per severity per stuck period; it re-arms when fresh messages arrive.
|
||||
We just translate severity to a log level.
|
||||
"""
|
||||
log = _LOGGER.error if severity == "error" else _LOGGER.warning
|
||||
log(
|
||||
"HomematicIP websocket has not received a message for "
|
||||
"%.0f seconds while reporting connected",
|
||||
seconds_since,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"HMIP websocket diagnostics: %s",
|
||||
self._websocket_diagnostic_context(),
|
||||
)
|
||||
|
||||
async def get_state(self) -> None:
|
||||
"""Update HMIP state and tell Home Assistant."""
|
||||
await self.home.get_current_state_async()
|
||||
self._post_state_refresh()
|
||||
|
||||
def _post_state_refresh(self) -> None:
|
||||
"""Apply HA-specific post-processing after a state refresh.
|
||||
|
||||
``set_all_to_unavailable`` marked every device unreach=True on
|
||||
disconnect; ``get_current_state_async`` only clears that flag for
|
||||
devices whose state actually changed during the outage, so the rest
|
||||
stay stuck unavailable after reconnect. Force-clear for all devices.
|
||||
Trade-off: a device that is *genuinely* unreachable on the cloud
|
||||
side will briefly appear available until its next state push
|
||||
corrects it. That self-corrects, while the previous behaviour left
|
||||
entities stuck unavailable indefinitely (core #160048).
|
||||
"""
|
||||
for device in self.home.devices:
|
||||
device.unreach = False
|
||||
self.update_all()
|
||||
|
||||
def get_state_finished(self, future) -> None:
|
||||
"""Execute when try_get_state coroutine has finished."""
|
||||
try:
|
||||
future.result()
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug("HomematicIP reconnect state refresh task was cancelled")
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.error(
|
||||
"Error updating state after HMIP access point reconnect: %s", err
|
||||
@@ -246,6 +307,7 @@ class HomematicipHAP:
|
||||
home.set_on_connected_handler(self.ws_connected_handler)
|
||||
home.set_on_disconnected_handler(self.ws_disconnected_handler)
|
||||
home.set_on_reconnect_handler(self.ws_reconnected_handler)
|
||||
home.set_on_websocket_stale_handler(self._on_websocket_stale)
|
||||
|
||||
async def async_reset(self) -> bool:
|
||||
"""Close the websocket connection."""
|
||||
@@ -275,23 +337,28 @@ class HomematicipHAP:
|
||||
"""Handle websocket connected."""
|
||||
_LOGGER.info("Websocket connection to HomematicIP Cloud established")
|
||||
if self._ws_connection_closed.is_set():
|
||||
self._get_state_task = self.hass.async_create_task(self._try_get_state())
|
||||
self._get_state_task.add_done_callback(self.get_state_finished)
|
||||
|
||||
self._ws_connection_closed.clear()
|
||||
self._start_get_state_task()
|
||||
|
||||
async def ws_disconnected_handler(self) -> None:
|
||||
"""Handle websocket disconnection."""
|
||||
_LOGGER.warning("Websocket connection to HomematicIP Cloud closed")
|
||||
_LOGGER.debug(
|
||||
"HMIP websocket diagnostics: %s",
|
||||
self._websocket_diagnostic_context(),
|
||||
)
|
||||
self._ws_connection_closed.set()
|
||||
|
||||
async def ws_reconnected_handler(self, reason: str) -> None:
|
||||
"""Handle websocket reconnection."""
|
||||
_LOGGER.info(
|
||||
"Websocket connection to HomematicIP Cloud trying"
|
||||
" to reconnect due to reason: %s",
|
||||
"Websocket connection to HomematicIP Cloud trying to reconnect due to "
|
||||
"reason: %s",
|
||||
reason,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"HMIP websocket diagnostics: %s",
|
||||
self._websocket_diagnostic_context(),
|
||||
)
|
||||
|
||||
self._ws_connection_closed.set()
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "The Honeywell integration needs to re-authenticate your account",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
|
||||
@@ -6,7 +6,7 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -41,6 +41,7 @@ class IAlarmPanel(
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)},
|
||||
manufacturer="Antifurto365 - Meian",
|
||||
name="iAlarm",
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from .const import (
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
from .media_source import async_setup_mediasource, async_setup_photo_cache
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -27,7 +28,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up iCloud integration."""
|
||||
|
||||
async_setup_services(hass)
|
||||
|
||||
async_setup_mediasource(hass)
|
||||
return True
|
||||
|
||||
|
||||
@@ -61,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
|
||||
entry.runtime_data = account
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await async_setup_photo_cache(hass, account)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import operator
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyicloud import PyiCloudService
|
||||
from pyicloud.exceptions import (
|
||||
@@ -55,6 +55,9 @@ from .const import (
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .media_source import PhotoCache
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type IcloudConfigEntry = ConfigEntry[IcloudAccount]
|
||||
@@ -95,6 +98,8 @@ class IcloudAccount:
|
||||
self._unsub_fetch: CALLBACK_TYPE | None = None
|
||||
self.listeners: list[CALLBACK_TYPE] = []
|
||||
|
||||
self.photo_cache: PhotoCache | None = None
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up an iCloud account."""
|
||||
try:
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
"name": "Apple iCloud",
|
||||
"codeowners": ["@Quentame", "@nzapponi"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/icloud",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["keyrings.alt", "pyicloud"],
|
||||
"requirements": ["pyicloud==2.4.1"]
|
||||
"requirements": ["pyicloud==2.6.5"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
"""Expose iCloud photo albums as a media source."""
|
||||
|
||||
from base64 import b64decode, b64encode
|
||||
import binascii
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import threading
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import ClientTimeout, hdrs, web
|
||||
from pyicloud.services.photos import (
|
||||
AlbumContainer,
|
||||
BasePhotoAlbum,
|
||||
PhotoAlbumFolder,
|
||||
PhotoAsset,
|
||||
)
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.http.static import CACHE_HEADERS
|
||||
from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
|
||||
from homeassistant.components.media_source import (
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
MediaSourceItem,
|
||||
PlayMedia,
|
||||
Unresolvable,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .account import IcloudAccount
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MAX_PHOTO_CACHE_SIZE = 1000
|
||||
|
||||
|
||||
def async_setup_mediasource(hass: HomeAssistant) -> None:
|
||||
"""Set up the iCloud media source."""
|
||||
hass.http.register_view(IcloudMediaSourceView(hass))
|
||||
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> IcloudMediaSource:
|
||||
"""Set up iCloud media source."""
|
||||
return IcloudMediaSource(hass)
|
||||
|
||||
|
||||
def _get_icloud_account_and_title(
|
||||
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
|
||||
) -> tuple[IcloudAccount, str]:
|
||||
"""Get iCloud account from identifier. Also return the account title for display purposes."""
|
||||
entry = hass.config_entries.async_entry_for_domain_unique_id(
|
||||
DOMAIN, identifier.config_entry_id
|
||||
)
|
||||
if entry is None:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_found",
|
||||
translation_placeholders={"entry": identifier.config_entry_id},
|
||||
)
|
||||
if getattr(entry, "runtime_data", None) is None:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="account_not_initialized",
|
||||
translation_placeholders={"entry": identifier.config_entry_id},
|
||||
)
|
||||
|
||||
return entry.runtime_data, entry.title
|
||||
|
||||
|
||||
async def async_setup_photo_cache(hass, account):
|
||||
"""Set up the photo cache for the iCloud account."""
|
||||
if account.photo_cache is None:
|
||||
account.photo_cache = PhotoCache()
|
||||
|
||||
|
||||
async def _get_photo_library(
|
||||
hass: HomeAssistant,
|
||||
icloud_account: IcloudAccount,
|
||||
identifier: IcloudMediaSourceIdentifier,
|
||||
) -> AlbumContainer:
|
||||
"""Get photo library."""
|
||||
|
||||
def get_photo_library_sync() -> AlbumContainer:
|
||||
"""Get photo library synchronously."""
|
||||
if icloud_account.api is None or icloud_account.api.photos is None:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="account_not_initialized",
|
||||
translation_placeholders={"entry": identifier.config_entry_id},
|
||||
)
|
||||
return (
|
||||
icloud_account.api.photos.shared_streams
|
||||
if identifier.shared_album is True
|
||||
else icloud_account.api.photos.albums
|
||||
)
|
||||
|
||||
return await hass.async_add_executor_job(get_photo_library_sync)
|
||||
|
||||
|
||||
async def _get_photo_album(
|
||||
hass: HomeAssistant,
|
||||
icloud_account: IcloudAccount,
|
||||
identifier: IcloudMediaSourceIdentifier,
|
||||
) -> BasePhotoAlbum:
|
||||
"""Get photo album from identifier."""
|
||||
|
||||
def _find_album_sync() -> BasePhotoAlbum | None:
|
||||
"""Find album synchronously."""
|
||||
album: BasePhotoAlbum | None = (
|
||||
albums.get(identifier.album_id) if albums and identifier.album_id else None
|
||||
)
|
||||
if not album:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="album_not_found",
|
||||
)
|
||||
return album
|
||||
|
||||
albums: AlbumContainer | None = None
|
||||
if icloud_account.api is None:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="account_not_initialized",
|
||||
translation_placeholders={"entry": identifier.config_entry_id},
|
||||
)
|
||||
|
||||
albums = await _get_photo_library(hass, icloud_account, identifier)
|
||||
|
||||
return await hass.async_add_executor_job(_find_album_sync)
|
||||
|
||||
|
||||
async def _get_photo_asset(
|
||||
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
|
||||
) -> PhotoAsset:
|
||||
"""Get photo asset asynchronously."""
|
||||
|
||||
def _get_photo_asset_sync(album: BasePhotoAlbum) -> PhotoAsset | None:
|
||||
"""Get photo asset synchronously."""
|
||||
for item in album.photos:
|
||||
if item.id == identifier.photo_id and identifier.photo_id is not None:
|
||||
PhotoCache.instance(icloud_account).set(identifier.photo_id, item)
|
||||
return item
|
||||
return None
|
||||
|
||||
icloud_account, _ = _get_icloud_account_and_title(hass, identifier)
|
||||
|
||||
if identifier.album_id is None or identifier.photo_id is None:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="incomplete_media_source_identifier",
|
||||
)
|
||||
|
||||
photo: PhotoAsset | None = await hass.async_add_executor_job(
|
||||
PhotoCache.instance(icloud_account).get, identifier.photo_id
|
||||
)
|
||||
if photo is None:
|
||||
album: BasePhotoAlbum = await _get_photo_album(hass, icloud_account, identifier)
|
||||
photo = await hass.async_add_executor_job(_get_photo_asset_sync, album)
|
||||
|
||||
if photo is None:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="photo_not_found",
|
||||
)
|
||||
return photo
|
||||
|
||||
|
||||
async def _get_media_mime_type(
|
||||
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
|
||||
) -> str:
|
||||
"""Get media MIME type asynchronously."""
|
||||
photo: PhotoAsset = await _get_photo_asset(hass, identifier)
|
||||
|
||||
match photo.item_type:
|
||||
case "image":
|
||||
if photo.filename.lower().endswith(".png"):
|
||||
return "image/png"
|
||||
if photo.filename.lower().endswith(".heic"):
|
||||
return "image/heic"
|
||||
return "image/jpeg"
|
||||
case "movie":
|
||||
return "video/mp4"
|
||||
case _:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_media_type",
|
||||
)
|
||||
|
||||
|
||||
class PhotoCache:
|
||||
"""Simple in-memory cache for PhotoAsset objects."""
|
||||
|
||||
@classmethod
|
||||
def instance(cls, icloud_account: IcloudAccount) -> PhotoCache:
|
||||
"""Get the account instance of the photo cache."""
|
||||
if icloud_account.photo_cache is None:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
)
|
||||
return icloud_account.photo_cache
|
||||
|
||||
def __init__(self, max_size: int = MAX_PHOTO_CACHE_SIZE) -> None:
|
||||
"""Initialize the photo cache."""
|
||||
self._cache: OrderedDict[str, PhotoAsset] = OrderedDict()
|
||||
self._max_size = max_size
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def get(self, photo_id: str) -> PhotoAsset | None:
|
||||
"""Get a photo from the cache."""
|
||||
with self._lock:
|
||||
photo = self._cache.get(photo_id)
|
||||
if photo is not None:
|
||||
# Move the accessed item to the end to show that it was recently used
|
||||
self._cache.move_to_end(photo_id)
|
||||
return photo
|
||||
|
||||
def set(self, photo_id: str, photo: PhotoAsset) -> None:
|
||||
"""Set a photo in the cache."""
|
||||
with self._lock:
|
||||
self._cache[photo_id] = photo
|
||||
if len(self._cache) > self._max_size:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class IcloudMediaSourceIdentifier:
|
||||
"""Parse and represent an iCloud media source identifier.
|
||||
|
||||
Example identifier format: config_entry_id/album/album_id
|
||||
Example identifier format: config_entry_id/shared/shared_album_id
|
||||
Example identifier format: config_entry_id/album/album_id/photo_id
|
||||
Example identifier format: config_entry_id/shared/shared_album_id/photo_id
|
||||
|
||||
"""
|
||||
|
||||
config_entry_id: str
|
||||
shared_album: bool | None = None
|
||||
album_id: str | None = None
|
||||
photo_id: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def from_identifier(identifier: str) -> IcloudMediaSourceIdentifier:
|
||||
"""Initialize iCloud media source identifier."""
|
||||
config_entry_id: str = ""
|
||||
shared_album: bool | None = None
|
||||
album_id: str | None = None
|
||||
photo_id: str | None = None
|
||||
parts: list[str] = identifier.split("/") if identifier else []
|
||||
|
||||
for idx, part in enumerate(parts):
|
||||
if idx == 0:
|
||||
config_entry_id = part
|
||||
elif idx == 1:
|
||||
if part.lower() not in ("shared", "album"):
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_view_type",
|
||||
)
|
||||
shared_album = part.lower() == "shared"
|
||||
elif idx == 2:
|
||||
album_id = part
|
||||
elif idx == 3:
|
||||
photo_id = part
|
||||
|
||||
if not config_entry_id:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="incomplete_media_source_identifier",
|
||||
)
|
||||
|
||||
return IcloudMediaSourceIdentifier(
|
||||
config_entry_id=config_entry_id,
|
||||
shared_album=shared_album,
|
||||
album_id=album_id,
|
||||
photo_id=photo_id,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return string representation of the identifier."""
|
||||
parts = [self.config_entry_id]
|
||||
if self.shared_album is not None:
|
||||
parts.append("shared" if self.shared_album else "album")
|
||||
if self.album_id is not None:
|
||||
parts.append(self.album_id)
|
||||
if self.photo_id is not None:
|
||||
parts.append(self.photo_id)
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
class IcloudMediaSource(MediaSource):
|
||||
"""Provide iCloud media source."""
|
||||
|
||||
name = "iCloud"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize iCloud media source."""
|
||||
super().__init__(DOMAIN)
|
||||
self._hass = hass
|
||||
|
||||
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Resolve a media item to a playable object."""
|
||||
if not self._hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
)
|
||||
|
||||
identifier = IcloudMediaSourceIdentifier.from_identifier(item.identifier)
|
||||
mime_type = await _get_media_mime_type(self._hass, identifier)
|
||||
|
||||
return PlayMedia(
|
||||
f"/api/icloud/media_source/serve/original/{b64encode(str(item.identifier).encode()).decode()}",
|
||||
mime_type,
|
||||
)
|
||||
|
||||
def _get_config_entries(self) -> list[ConfigEntry]:
|
||||
"""Get iCloud config entries."""
|
||||
return self._hass.config_entries.async_entries(
|
||||
DOMAIN, include_disabled=False, include_ignore=False
|
||||
)
|
||||
|
||||
async def _build_title_for_identifier(
|
||||
self,
|
||||
identifier: IcloudMediaSourceIdentifier | None,
|
||||
) -> str:
|
||||
"""Build title for media source identifier."""
|
||||
title_parts = ["iCloud Media"]
|
||||
icloud_account = None
|
||||
|
||||
if identifier and identifier.config_entry_id is not None:
|
||||
icloud_account, title = _get_icloud_account_and_title(
|
||||
self._hass, identifier
|
||||
)
|
||||
title_parts.append(title)
|
||||
|
||||
if identifier and identifier.shared_album is True:
|
||||
title_parts.append("Shared Streams")
|
||||
elif identifier and identifier.shared_album is False:
|
||||
title_parts.append("Albums")
|
||||
|
||||
if icloud_account and identifier and identifier.album_id is not None:
|
||||
album = await _get_photo_album(self._hass, icloud_account, identifier)
|
||||
title_parts.append(album.title)
|
||||
|
||||
return " / ".join(title_parts)
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
item: MediaSourceItem,
|
||||
) -> BrowseMediaSource:
|
||||
"""Return media."""
|
||||
if not self._hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
)
|
||||
|
||||
if not item.identifier:
|
||||
return await self._async_build_icloud_accounts()
|
||||
|
||||
identifier = IcloudMediaSourceIdentifier.from_identifier(item.identifier)
|
||||
|
||||
if identifier.shared_album is None:
|
||||
return await self._async_build_album_types(identifier)
|
||||
|
||||
icloud_account, _ = _get_icloud_account_and_title(self._hass, identifier)
|
||||
|
||||
if identifier.album_id is None:
|
||||
return await self._async_build_albums(identifier, icloud_account)
|
||||
|
||||
if identifier.photo_id is None:
|
||||
return await self._async_build_photos(identifier, icloud_account)
|
||||
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_media_item",
|
||||
)
|
||||
|
||||
async def _async_build_icloud_accounts(
|
||||
self,
|
||||
) -> BrowseMediaSource:
|
||||
"""Handle browsing of different iCloud accounts."""
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaType.ALBUM,
|
||||
title=await self._build_title_for_identifier(None),
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
children=[
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=str(
|
||||
IcloudMediaSourceIdentifier(config_entry_id=entry.unique_id)
|
||||
),
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaType.ALBUM,
|
||||
title=entry.title,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
)
|
||||
for entry in self._get_config_entries()
|
||||
if entry.unique_id is not None
|
||||
],
|
||||
)
|
||||
|
||||
async def _async_build_album_types(
|
||||
self,
|
||||
identifier: IcloudMediaSourceIdentifier,
|
||||
) -> BrowseMediaSource:
|
||||
"""Handle browsing of album types (albums vs shared albums)."""
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaType.ALBUM,
|
||||
title=await self._build_title_for_identifier(identifier),
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
children=[
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=str(
|
||||
IcloudMediaSourceIdentifier(
|
||||
config_entry_id=identifier.config_entry_id,
|
||||
shared_album=False,
|
||||
)
|
||||
),
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaType.ALBUM,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
title="Albums",
|
||||
),
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=str(
|
||||
IcloudMediaSourceIdentifier(
|
||||
config_entry_id=identifier.config_entry_id,
|
||||
shared_album=True,
|
||||
)
|
||||
),
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaType.ALBUM,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
title="Shared Streams",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
async def _async_build_albums(
|
||||
self,
|
||||
identifier: IcloudMediaSourceIdentifier,
|
||||
icloud_account: IcloudAccount,
|
||||
) -> BrowseMediaSource:
|
||||
"""Handle browsing of albums."""
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaType.ALBUM,
|
||||
title=await self._build_title_for_identifier(identifier),
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
children=await self._browse_albums(identifier, icloud_account),
|
||||
)
|
||||
|
||||
async def _async_build_photos(
|
||||
self,
|
||||
identifier: IcloudMediaSourceIdentifier,
|
||||
icloud_account: IcloudAccount,
|
||||
) -> BrowseMediaSource:
|
||||
"""Handle browsing of photos in an album."""
|
||||
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaType.ALBUM,
|
||||
title=await self._build_title_for_identifier(identifier),
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
children=await self._get_photo_list(identifier, icloud_account),
|
||||
)
|
||||
|
||||
async def _browse_albums(
|
||||
self,
|
||||
identifier: IcloudMediaSourceIdentifier,
|
||||
icloud_account: IcloudAccount,
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Browse albums asynchronously."""
|
||||
|
||||
albums: AlbumContainer | None = None
|
||||
if icloud_account.api is None:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="account_not_initialized",
|
||||
translation_placeholders={"entry": identifier.config_entry_id},
|
||||
)
|
||||
|
||||
albums = await _get_photo_library(self._hass, icloud_account, identifier)
|
||||
|
||||
children: list[BrowseMediaSource] = []
|
||||
if albums is not None:
|
||||
for album in albums:
|
||||
if isinstance(album, PhotoAlbumFolder):
|
||||
continue
|
||||
children.append(
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=str(
|
||||
IcloudMediaSourceIdentifier(
|
||||
config_entry_id=identifier.config_entry_id,
|
||||
shared_album=identifier.shared_album,
|
||||
album_id=album.id,
|
||||
)
|
||||
),
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaType.ALBUM,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
title=album.title,
|
||||
)
|
||||
)
|
||||
return children
|
||||
|
||||
async def _get_photo_list(
|
||||
self,
|
||||
identifier: IcloudMediaSourceIdentifier,
|
||||
icloud_account: IcloudAccount,
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Get list of photos asynchronously."""
|
||||
|
||||
def _get_photo_list_sync(album: BasePhotoAlbum) -> list[BrowseMediaSource]:
|
||||
"""Get list of photos synchronously."""
|
||||
items: list[BrowseMediaSource] = []
|
||||
for photo in album.photos:
|
||||
PhotoCache.instance(icloud_account).set(photo.id, photo)
|
||||
photo_id = IcloudMediaSourceIdentifier(
|
||||
config_entry_id=identifier.config_entry_id,
|
||||
shared_album=identifier.shared_album,
|
||||
album_id=identifier.album_id,
|
||||
photo_id=photo.id,
|
||||
)
|
||||
|
||||
item = BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=str(photo_id),
|
||||
media_class=(
|
||||
MediaClass.IMAGE
|
||||
if photo.item_type == "image"
|
||||
else MediaClass.VIDEO
|
||||
),
|
||||
media_content_type=(
|
||||
MediaType.IMAGE
|
||||
if photo.item_type == "image"
|
||||
else MediaType.VIDEO
|
||||
),
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
title=photo.filename,
|
||||
thumbnail=f"/api/icloud/media_source/serve/thumb{'' if photo.item_type == 'image' else '_image'}/{b64encode(str(photo_id).encode()).decode()}",
|
||||
)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
album: BasePhotoAlbum = await _get_photo_album(
|
||||
self._hass, icloud_account, identifier
|
||||
)
|
||||
return await self._hass.async_add_executor_job(_get_photo_list_sync, album)
|
||||
|
||||
|
||||
class IcloudMediaSourceView(HomeAssistantView):
|
||||
"""Handle media serving via HTTP view."""
|
||||
|
||||
url = "/api/icloud/media_source/serve/{version}/{image_id}"
|
||||
name = "api:icloud:media_source:serve"
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize iCloud media source view."""
|
||||
super().__init__()
|
||||
self._hass = hass
|
||||
self.session = async_get_clientsession(hass)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
request: web.Request,
|
||||
version: str,
|
||||
image_id: str,
|
||||
) -> web.StreamResponse:
|
||||
"""Get the image from iCloud."""
|
||||
|
||||
try:
|
||||
identifier = IcloudMediaSourceIdentifier.from_identifier(
|
||||
b64decode(image_id, validate=True).decode()
|
||||
)
|
||||
except (Unresolvable, binascii.Error, UnicodeDecodeError) as err:
|
||||
_LOGGER.error("Error decoding iCloud media source identifier: %s", err)
|
||||
raise web.HTTPBadRequest from err
|
||||
|
||||
try:
|
||||
photo = await _get_photo_asset(self._hass, identifier)
|
||||
except Unresolvable as err:
|
||||
_LOGGER.error("Error resolving iCloud media source: %s", err)
|
||||
raise web.HTTPNotFound from err
|
||||
|
||||
url = photo.versions.get(version, {}).get("url")
|
||||
if url is None and version.startswith("thumb"):
|
||||
# try the medium version for thumbnails if the requested version is not available, as some videos only have a medium version and no separate thumbnail version
|
||||
url = photo.versions.get(version.replace("thumb", "medium"), {}).get("url")
|
||||
if url is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
request_headers = {}
|
||||
if hdrs.RANGE in request.headers:
|
||||
request_headers[hdrs.RANGE] = request.headers[hdrs.RANGE]
|
||||
|
||||
icloud_response = await self.session.get(
|
||||
url,
|
||||
timeout=ClientTimeout(
|
||||
connect=15, sock_connect=15, sock_read=30, total=None
|
||||
),
|
||||
headers=request_headers,
|
||||
)
|
||||
|
||||
response_headers: dict[str, str] = {}
|
||||
response_headers.update(CACHE_HEADERS)
|
||||
response_headers[hdrs.CONTENT_DISPOSITION] = (
|
||||
f'attachment;filename="{urllib.parse.quote(photo.filename, safe="")}"'
|
||||
)
|
||||
|
||||
for header in (
|
||||
hdrs.CONTENT_TYPE,
|
||||
hdrs.LAST_MODIFIED,
|
||||
hdrs.ACCEPT_RANGES,
|
||||
hdrs.CONTENT_RANGE,
|
||||
):
|
||||
if header in icloud_response.headers:
|
||||
response_headers[header] = icloud_response.headers[header]
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=icloud_response.status,
|
||||
reason=icloud_response.reason,
|
||||
headers=response_headers,
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
try:
|
||||
async for chunk in icloud_response.content.iter_chunked(65536):
|
||||
await response.write(chunk)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Timeout while reading iCloud, writing EOF",
|
||||
)
|
||||
finally:
|
||||
icloud_response.release()
|
||||
|
||||
await response.write_eof()
|
||||
return response
|
||||
@@ -44,6 +44,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"account_not_initialized": {
|
||||
"message": "Account not initialized: {entry}"
|
||||
},
|
||||
"album_not_found": {
|
||||
"message": "Album not found"
|
||||
},
|
||||
"album_type_not_specified": {
|
||||
"message": "Album type not specified"
|
||||
},
|
||||
"config_entry_not_found": {
|
||||
"message": "Config entry not found for account: {entry}"
|
||||
},
|
||||
"config_entry_not_loaded": {
|
||||
"message": "Config entry not loaded"
|
||||
},
|
||||
"incomplete_media_source_identifier": {
|
||||
"message": "Incomplete media source identifier"
|
||||
},
|
||||
"invalid_media_source": {
|
||||
"message": "Invalid media source"
|
||||
},
|
||||
"invalid_view_type": {
|
||||
"message": "Invalid album view type"
|
||||
},
|
||||
"photo_not_found": {
|
||||
"message": "Photo not found"
|
||||
},
|
||||
"unknown_media_item": {
|
||||
"message": "Unknown media item"
|
||||
},
|
||||
"unsupported_media_type": {
|
||||
"message": "Unsupported media type"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"display_message": {
|
||||
"description": "Displays a message on an Apple device.",
|
||||
|
||||
@@ -326,7 +326,9 @@ class ImageView(HomeAssistantView):
|
||||
) -> ImageEntity:
|
||||
"""Authenticate request and return image entity."""
|
||||
if (image_entity := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
raise (
|
||||
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
|
||||
)
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
@@ -334,11 +336,15 @@ class ImageView(HomeAssistantView):
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or image entity access token
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
return image_entity
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/influxdb",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["influxdb", "influxdb_client"],
|
||||
"requirements": ["influxdb==5.3.1", "influxdb-client==1.50.0"],
|
||||
"requirements": ["influxdb==5.3.2", "influxdb-client==1.50.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["infrared-protocols==6.0.0"]
|
||||
"requirements": ["infrared-protocols==6.0.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["geopy", "pyipma"],
|
||||
"requirements": ["pyipma==3.0.9"]
|
||||
"requirements": ["pyipma==3.0.10"]
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"protocol": "Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Hostname or IP address of your Iskra device."
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["pykaleidescape==1.1.5"],
|
||||
"requirements": ["pykaleidescape==1.1.6"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "schemas-upnp-org:device:Basic:1",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user