mirror of
https://github.com/home-assistant/core.git
synced 2026-06-16 08:52:53 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1274426bd | |||
| d43c6e6991 |
@@ -6,7 +6,6 @@
|
||||
|
||||
- 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
|
||||
@@ -51,5 +50,4 @@ 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -50,24 +50,19 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Install script dependencies
|
||||
run: pip install -r script/check_requirements/requirements.txt
|
||||
- name: Collect PR diff and head SHA
|
||||
id: pr
|
||||
- name: Collect PR diff
|
||||
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
|
||||
|
||||
+3
-76
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"328ce915f8b178bbcfcb1b69f397ac996456a4ab50490805b5b3bdd26cbf58fe","body_hash":"0665c72fe4b0a14b3a0b396dc293ce78235771fe15ebc894662fece33b189135","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# 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-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,7 +345,6 @@ jobs:
|
||||
needs:
|
||||
- activation
|
||||
- extract_pr_number
|
||||
- gate
|
||||
if: needs.activation.outputs.daily_effective_workflow_exceeded != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -995,7 +994,6 @@ jobs:
|
||||
- agent
|
||||
- detection
|
||||
- extract_pr_number
|
||||
- gate
|
||||
- safe_outputs
|
||||
if: >
|
||||
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
|
||||
@@ -1430,8 +1428,8 @@ jobs:
|
||||
}
|
||||
|
||||
extract_pr_number:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
|
||||
needs: activation
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -1462,77 +1460,6 @@ 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.
|
||||
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
|
||||
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
|
||||
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
|
||||
| grep -oE '[0-9a-f]{7,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,68 +22,8 @@ safe-outputs:
|
||||
needs:
|
||||
- extract_pr_number
|
||||
jobs:
|
||||
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.
|
||||
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
|
||||
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
|
||||
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
|
||||
| grep -oE '[0-9a-f]{7,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'
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -188,10 +128,8 @@ Then stop. Do not improvise a verdict.
|
||||
|
||||
Replace every placeholder with the resolved value and emit
|
||||
`rendered_comment` via `add_comment`. Preserve the leading
|
||||
`<!-- requirements-check -->` marker and the
|
||||
`<!-- requirements-check-sha: … -->` marker that follows it — the next
|
||||
run reads the recorded commit from it to decide whether anything changed.
|
||||
The PR target is already wired; do not pass `item_number`.
|
||||
`<!-- requirements-check -->` marker. The PR target is already wired;
|
||||
do not pass `item_number`.
|
||||
|
||||
## Check instructions
|
||||
|
||||
|
||||
@@ -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|homeassistant/components/sensor/const\.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|requirements.+\.txt)$
|
||||
- id: hassfest-metadata
|
||||
name: hassfest-metadata
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
|
||||
|
||||
@@ -40,5 +40,4 @@ 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
Generated
+1
-3
@@ -695,8 +695,6 @@ 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
|
||||
@@ -1157,6 +1155,7 @@ 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
|
||||
@@ -1892,7 +1891,6 @@ 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,6 +11,7 @@
|
||||
"microsoft_face_identify",
|
||||
"microsoft_face",
|
||||
"microsoft",
|
||||
"msteams",
|
||||
"onedrive",
|
||||
"onedrive_for_business",
|
||||
"xbox"
|
||||
|
||||
@@ -26,5 +26,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioacaia"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioacaia==0.1.18"]
|
||||
"requirements": ["aioacaia==0.1.17"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["serialx==1.8.2"]
|
||||
"requirements": ["serialx==1.8.0"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acmeda",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiopulse"],
|
||||
"requirements": ["aiopulse==0.4.7"]
|
||||
"requirements": ["aiopulse==0.4.6"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""The AirVisual Pro integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -28,7 +32,7 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
|
||||
connections={
|
||||
(
|
||||
CONNECTION_NETWORK_MAC,
|
||||
self.coordinator.data["status"]["mac_address"],
|
||||
format_mac(self.coordinator.data["status"]["mac_address"]),
|
||||
)
|
||||
},
|
||||
manufacturer="AirVisual",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.0.4"]
|
||||
"requirements": ["aioamazondevices==14.0.3"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["anova_wifi"],
|
||||
"requirements": ["anova-wifi==0.17.1"]
|
||||
"requirements": ["anova-wifi==0.17.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["anthemav"],
|
||||
"requirements": ["anthemav==1.4.2"]
|
||||
"requirements": ["anthemav==1.4.1"]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -50,7 +49,6 @@ 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."""
|
||||
@@ -104,25 +102,10 @@ 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 aqvify_device in devices.devices.values():
|
||||
for device in devices.devices.values():
|
||||
try:
|
||||
device_key = str(aqvify_device.device_key)
|
||||
device_key = str(device.device_key)
|
||||
device_data[
|
||||
device_key
|
||||
] = await self.api_client.async_get_device_latest_data(device_key)
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyaqvify"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyaqvify==0.0.9"]
|
||||
}
|
||||
|
||||
@@ -29,28 +29,16 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any actions.
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
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.
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: todo
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.1"]
|
||||
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
@@ -76,21 +75,6 @@ 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]:
|
||||
@@ -117,50 +101,45 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
|
||||
)
|
||||
|
||||
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(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
|
||||
)
|
||||
|
||||
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: host})
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {
|
||||
"name": self.device_config["name"],
|
||||
"host": 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
|
||||
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)
|
||||
|
||||
api_host = ApiHost(
|
||||
*ipaddress, DEFAULT_SETUP_TIMEOUT, websession, 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")
|
||||
|
||||
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.context.update(
|
||||
{
|
||||
"title_placeholders": {
|
||||
"name": self.device_config["name"],
|
||||
"host": self.device_config["host"],
|
||||
},
|
||||
"configuration_url": f"http://{discovery_info.host}",
|
||||
}
|
||||
)
|
||||
return await self.async_step_confirm_discovery()
|
||||
|
||||
async def async_step_confirm_discovery(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -179,6 +158,7 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={
|
||||
"name": self.device_config["name"],
|
||||
"host": self.device_config["host"],
|
||||
"port": self.device_config["port"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -3,45 +3,6 @@
|
||||
"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,7 +4,6 @@
|
||||
"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.",
|
||||
@@ -19,10 +18,6 @@
|
||||
},
|
||||
"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%]",
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["blinkpy"],
|
||||
"requirements": ["blinkpy==0.25.6"]
|
||||
"requirements": ["blinkpy==0.25.2"]
|
||||
}
|
||||
|
||||
@@ -26,12 +26,6 @@
|
||||
"description": "The credentials for {username} need to be updated",
|
||||
"title": "Re-authenticate Blink"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["bluecurrent_api"],
|
||||
"requirements": ["bluecurrent-api==1.3.3"]
|
||||
"requirements": ["bluecurrent-api==1.3.2"]
|
||||
}
|
||||
|
||||
@@ -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, sync_status.mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
|
||||
name=sync_status.name,
|
||||
manufacturer=sync_status.brand,
|
||||
model=sync_status.model_name,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bluesound",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyblu==2.0.8"],
|
||||
"requirements": ["pyblu==2.0.6"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_musc._tcp.local."
|
||||
|
||||
@@ -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, sync_status.mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_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, shc_info.unique_id)},
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(shc_info.unique_id))},
|
||||
identifiers={(DOMAIN, shc_info.unique_id)},
|
||||
manufacturer="Bosch",
|
||||
name=entry.title,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["boschshcpy"],
|
||||
"requirements": ["boschshcpy==0.2.111"],
|
||||
"requirements": ["boschshcpy==0.2.107"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bosch shc*",
|
||||
|
||||
@@ -118,14 +118,7 @@ class BroadlinkDevice[_ApiT: blk.Device = blk.Device]:
|
||||
return False
|
||||
|
||||
except (NetworkTimeoutError, OSError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connect_failed",
|
||||
translation_placeholders={
|
||||
"host": api.host[0],
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
except BroadlinkException as err:
|
||||
_LOGGER.error(
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connect_failed": {
|
||||
"message": "Failed to connect to the device at {host}: {error}"
|
||||
},
|
||||
"frequency_not_supported": {
|
||||
"message": "Broadlink devices cannot transmit on {frequency} MHz"
|
||||
},
|
||||
|
||||
@@ -31,7 +31,11 @@ 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
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
@@ -71,7 +75,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, device.MAC)},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))},
|
||||
name=device.name,
|
||||
manufacturer="BSBLAN Inc.",
|
||||
model=(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["buienradar", "vincenty"],
|
||||
"requirements": ["buienradar==1.0.9"]
|
||||
"requirements": ["buienradar==1.0.6"]
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ from homeassistant.components.websocket_api import (
|
||||
ActiveConnection,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EVENT, STATE_OFF, STATE_ON
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
HomeAssistant,
|
||||
@@ -45,6 +45,7 @@ from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.json import JsonValueType
|
||||
|
||||
from .const import (
|
||||
CONF_EVENT,
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
EVENT_DESCRIPTION,
|
||||
|
||||
@@ -13,6 +13,9 @@ if TYPE_CHECKING:
|
||||
DOMAIN = "calendar"
|
||||
DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN)
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_EVENT = "event"
|
||||
|
||||
|
||||
class CalendarEntityFeature(IntFlag):
|
||||
"""Supported features of the calendar entity."""
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
from cieloconnectapi.device import CieloDeviceAPI
|
||||
from cieloconnectapi.model import CieloDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -65,7 +69,7 @@ class CieloDeviceEntity(CieloBaseEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
|
||||
manufacturer="Cielo",
|
||||
configuration_url="https://home.cielowigle.com/",
|
||||
suggested_area=device.name,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.5"]
|
||||
"requirements": ["aiocomelit==2.0.3"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from hassil.recognize import RecognizeResult
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import MATCH_ALL, SERVICE_RELOAD
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
@@ -53,6 +53,7 @@ from .const import (
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
SERVICE_PROCESS,
|
||||
SERVICE_RELOAD,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .default_agent import async_setup_default_agent
|
||||
|
||||
@@ -19,6 +19,8 @@ ATTR_AGENT_ID = "agent_id"
|
||||
ATTR_CONVERSATION_ID = "conversation_id"
|
||||
|
||||
SERVICE_PROCESS = "process"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_RELOAD = "reload"
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 (
|
||||
@@ -57,13 +56,6 @@ 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)
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["datadog"],
|
||||
"requirements": ["datadog==0.52.1"]
|
||||
"requirements": ["datadog==0.52.0"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["debugpy==1.8.21"]
|
||||
"requirements": ["debugpy==1.8.17"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denonavr"],
|
||||
"requirements": ["denonavr==1.3.3"],
|
||||
"requirements": ["denonavr==1.3.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["HomeControl", "Mydevolo", "MprmRest", "MprmWebsocket", "Mprm"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["devolo-home-control-api==0.19.1"],
|
||||
"requirements": ["devolo-home-control-api==0.19.0"],
|
||||
"zeroconf": ["_dvl-deviceapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -19,19 +19,11 @@ from .coordinator import DucoConfigEntry
|
||||
TO_REDACT = {
|
||||
CONF_HOST,
|
||||
"mac",
|
||||
"Mac",
|
||||
"host_name",
|
||||
"HostName",
|
||||
"serial_board_box",
|
||||
"SerialBoardBox",
|
||||
"serial_board_comm",
|
||||
"SerialBoardComm",
|
||||
"serial_duco_box",
|
||||
"SerialDucoBox",
|
||||
"serial_duco_comm",
|
||||
"SerialDucoComm",
|
||||
"WifiApKey",
|
||||
"WifiApSsid",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -95,12 +95,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
value_fn=lambda node: node.sensor.co2 if node.sensor else None,
|
||||
node_types=(
|
||||
NodeType.BSCO2,
|
||||
NodeType.UCCO2,
|
||||
NodeType.VLVCO2,
|
||||
NodeType.VLVCO2RH,
|
||||
),
|
||||
node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="iaq_co2",
|
||||
@@ -109,12 +104,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda node: node.sensor.iaq_co2 if node.sensor else None,
|
||||
node_types=(
|
||||
NodeType.BSCO2,
|
||||
NodeType.UCCO2,
|
||||
NodeType.VLVCO2,
|
||||
NodeType.VLVCO2RH,
|
||||
),
|
||||
node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="humidity",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Base entity for the Elgato integration."""
|
||||
|
||||
from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -29,4 +33,6 @@ 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, mac)}
|
||||
self._attr_device_info[ATTR_CONNECTIONS] = {
|
||||
(CONNECTION_NETWORK_MAC, format_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, iface["mac"])
|
||||
(CONNECTION_NETWORK_MAC, format_mac(iface["mac"]))
|
||||
for iface in about["info"]["ifaces"]
|
||||
if "mac" in iface and iface["mac"] is not None
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.9"],
|
||||
"requirements": ["pyenphase==2.4.8"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.3.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.9.4"
|
||||
"bleak-esphome==3.9.1"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bleak", "fjaraskupan"],
|
||||
"requirements": ["fjaraskupan==2.3.4"]
|
||||
"requirements": ["fjaraskupan==2.3.3"]
|
||||
}
|
||||
|
||||
@@ -13,11 +13,6 @@
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to set up {model} {id} ({ipaddr})?"
|
||||
},
|
||||
"pick_device": {
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::device%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"data_description_password": "Password for the FRITZ!Box.",
|
||||
"data_description_port": "Leave empty to use the default port.",
|
||||
"data_description_ssl": "Use SSL to connect to the FRITZ!Box.",
|
||||
"data_description_username": "Username for the FRITZ!Box. FRITZ!Powerline devices ignore this information and accept any value.",
|
||||
"data_description_username": "Username for the FRITZ!Box.",
|
||||
"data_feature_device_tracking": "Enable network device tracking"
|
||||
},
|
||||
"config": {
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["fyta_cli"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["fyta_cli==0.7.3"]
|
||||
"requirements": ["fyta_cli==0.7.2"]
|
||||
}
|
||||
|
||||
@@ -65,16 +65,14 @@ 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(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/geniushub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["geniushubclient"],
|
||||
"requirements": ["geniushub-client==0.7.4"]
|
||||
"requirements": ["geniushub-client==0.7.1"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::device%]",
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
|
||||
@@ -230,19 +230,11 @@ 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=[
|
||||
prompt,
|
||||
self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT),
|
||||
Part.from_bytes(
|
||||
data=audio_data,
|
||||
mime_type=f"audio/{metadata.format.value}",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["gtts"],
|
||||
"requirements": ["gTTS==2.5.4"]
|
||||
"requirements": ["gTTS==2.5.3"]
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,192 +0,0 @@
|
||||
"""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},
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
"""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]
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
@@ -1,341 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"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,8 +201,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
) from err
|
||||
total_info["todayEnergy"] = total_info["today_energy"]
|
||||
total_info["totalEnergy"] = total_info["total_energy"]
|
||||
# V1 API returns current_power in kW, convert to W
|
||||
total_info["invTodayPpv"] = total_info["current_power"] * 1000
|
||||
total_info["invTodayPpv"] = total_info["current_power"]
|
||||
else:
|
||||
# Classic API: use plant_info as before.
|
||||
# Copy the response to avoid mutating the dict returned by the library
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhik"],
|
||||
"requirements": ["pyHik==0.4.3"]
|
||||
"requirements": ["pyHik==0.4.2"]
|
||||
}
|
||||
|
||||
@@ -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, mac))
|
||||
connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)))
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
|
||||
@@ -151,13 +151,6 @@ class HolidayCalendarEntity(CalendarEntity):
|
||||
"""Set up first update."""
|
||||
self._update_state_and_setup_listener()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Cancel listener when removing."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if self.unsub:
|
||||
self.unsub()
|
||||
self.unsub = None
|
||||
|
||||
def update_event(self, now: datetime) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
next_holiday = None
|
||||
|
||||
@@ -83,7 +83,9 @@ 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, homee.settings.mac_address)},
|
||||
connections={
|
||||
(dr.CONNECTION_NETWORK_MAC, dr.format_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: done
|
||||
docs-examples: todo
|
||||
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, hkid)}
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(hkid))}
|
||||
)
|
||||
|
||||
if device is None:
|
||||
|
||||
@@ -22,7 +22,4 @@ async def async_get_config_entry_diagnostics(
|
||||
anonymized = handle_config(json_state, anonymize=True)
|
||||
config = json.loads(anonymized)
|
||||
|
||||
return {
|
||||
"websocket": hap.websocket_diagnostics(),
|
||||
"config": async_redact_data(config, TO_REDACT_CONFIG),
|
||||
}
|
||||
return async_redact_data(config, TO_REDACT_CONFIG)
|
||||
|
||||
@@ -164,11 +164,9 @@ class HomematicipHAP:
|
||||
self.set_all_to_unavailable()
|
||||
elif self._ws_connection_closed.is_set():
|
||||
_LOGGER.info("HMIP access point has reconnected to the cloud")
|
||||
_LOGGER.debug(
|
||||
"HMIP websocket diagnostics: %s",
|
||||
self._websocket_diagnostic_context(),
|
||||
)
|
||||
self._start_get_state_task()
|
||||
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()
|
||||
|
||||
@callback
|
||||
def async_create_entity(self, *args, **kwargs) -> None:
|
||||
@@ -182,103 +180,44 @@ 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:
|
||||
"""Refresh state after a websocket reconnect.
|
||||
"""Call get_state in a loop until no error occurs.
|
||||
|
||||
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)
|
||||
Uses exponential backoff on error.
|
||||
"""
|
||||
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()
|
||||
|
||||
async def _on_websocket_stale(self, severity: str, seconds_since: float) -> None:
|
||||
"""Log a websocket-stale event surfaced by the library.
|
||||
# Wait until WebSocket connection is established.
|
||||
while not self.home.websocket_is_connected():
|
||||
await asyncio.sleep(2)
|
||||
|
||||
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(),
|
||||
)
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -307,7 +246,6 @@ 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."""
|
||||
@@ -337,28 +275,23 @@ class HomematicipHAP:
|
||||
"""Handle websocket connected."""
|
||||
_LOGGER.info("Websocket connection to HomematicIP Cloud established")
|
||||
if self._ws_connection_closed.is_set():
|
||||
self._start_get_state_task()
|
||||
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 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()
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ from homematicip.device import (
|
||||
PassageDetector,
|
||||
PresenceDetectorIndoor,
|
||||
RoomControlDeviceAnalog,
|
||||
RotaryHandleSensor,
|
||||
SmokeDetector,
|
||||
SoilMoistureSensorInterface,
|
||||
SwitchMeasuring,
|
||||
@@ -167,7 +166,6 @@ ILLUMINATION_DEVICE_ATTRIBUTES = {
|
||||
}
|
||||
|
||||
TILT_STATE_VALUES = ["neutral", "tilted", "non_neutral"]
|
||||
WINDOW_STATE_VALUES = ["open", "closed", "tilted"]
|
||||
|
||||
|
||||
def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
|
||||
@@ -206,9 +204,6 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
|
||||
RoomControlDeviceAnalog: lambda device: [
|
||||
HomematicipTemperatureSensor(hap, device),
|
||||
],
|
||||
RotaryHandleSensor: lambda device: [
|
||||
HomematicipWindowStateSensor(hap, device),
|
||||
],
|
||||
LightSensor: lambda device: [
|
||||
HomematicipIlluminanceSensor(hap, device),
|
||||
],
|
||||
@@ -503,24 +498,6 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity):
|
||||
return state_attr
|
||||
|
||||
|
||||
class HomematicipWindowStateSensor(HomematicipGenericEntity, SensorEntity):
|
||||
"""Representation of the HomematicIP rotary handle window state sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
_attr_options = WINDOW_STATE_VALUES
|
||||
_attr_translation_key = "window_state"
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device: RotaryHandleSensor) -> None:
|
||||
"""Initialize the window state sensor."""
|
||||
super().__init__(hap, device, feature_id="window_state")
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state."""
|
||||
window_state = getattr(self._device, "windowState", None)
|
||||
return window_state.lower() if window_state is not None else None
|
||||
|
||||
|
||||
class HomematicipFloorTerminalBlockMechanicChannelValve(
|
||||
HomematicipGenericEntity, SensorEntity
|
||||
):
|
||||
|
||||
@@ -98,14 +98,6 @@
|
||||
"non_neutral": "Non-neutral",
|
||||
"tilted": "Tilted"
|
||||
}
|
||||
},
|
||||
"window_state": {
|
||||
"name": "Window state",
|
||||
"state": {
|
||||
"closed": "[%key:common::state::closed%]",
|
||||
"open": "[%key:common::state::open%]",
|
||||
"tilted": "Tilted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,12 +50,14 @@ def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
|
||||
translation_key="auth_failed",
|
||||
) from error
|
||||
except HomevoltConnectionError as error:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
except HomevoltError as error:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_error",
|
||||
|
||||
@@ -172,10 +172,10 @@
|
||||
"message": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"communication_error": {
|
||||
"message": "Error communicating with the Homevolt battery: {error}"
|
||||
"message": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"unknown_error": {
|
||||
"message": "Unknown error from the Homevolt battery: {error}"
|
||||
"message": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"id": "Hue bridge"
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Hue bridge."
|
||||
|
||||
@@ -18,7 +18,6 @@ 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)
|
||||
@@ -28,7 +27,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up iCloud integration."""
|
||||
|
||||
async_setup_services(hass)
|
||||
async_setup_mediasource(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -62,7 +61,6 @@ 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 TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from pyicloud import PyiCloudService
|
||||
from pyicloud.exceptions import (
|
||||
@@ -55,9 +55,6 @@ from .const import (
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .media_source import PhotoCache
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type IcloudConfigEntry = ConfigEntry[IcloudAccount]
|
||||
@@ -98,8 +95,6 @@ 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,7 +3,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,671 +0,0 @@
|
||||
"""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,41 +44,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyimouapi==1.2.8"]
|
||||
"requirements": ["pyimouapi==1.2.7"]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ from homeassistant.const import (
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_OPTIONS,
|
||||
SERVICE_RELOAD,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
@@ -38,6 +37,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DOMAIN = "input_select"
|
||||
|
||||
CONF_INITIAL = "initial"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
SERVICE_SET_OPTIONS = "set_options"
|
||||
STORAGE_KEY = DOMAIN
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["deepmerge", "pyipp"],
|
||||
"requirements": ["pyipp==0.17.2"],
|
||||
"requirements": ["pyipp==0.17.0"],
|
||||
"zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyiskra"],
|
||||
"requirements": ["pyiskra==0.1.29"]
|
||||
"requirements": ["pyiskra==0.1.27"]
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"description": "Do you want to set up Islamic Prayer Times?",
|
||||
"title": "Set up Islamic Prayer Times"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsraelRailConfigEntry) -
|
||||
try:
|
||||
await hass.async_add_executor_job(train_schedule.query, start, destination)
|
||||
except Exception as e:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_timeout",
|
||||
|
||||
@@ -65,10 +65,5 @@
|
||||
"name": "Trains +2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"request_timeout": {
|
||||
"message": "Timeout connecting to the Israel Rail API for {config_title}: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyituran==0.1.6"]
|
||||
"requirements": ["pyituran==0.1.5"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
|
||||
from jvcprojector import Command, JvcProjector
|
||||
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER, NAME
|
||||
@@ -27,12 +27,8 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]):
|
||||
super().__init__(coordinator, command)
|
||||
|
||||
self._attr_unique_id = coordinator.unique_id
|
||||
# The config entry unique id is the device's formatted MAC address (set
|
||||
# from the projector's MAC in the config flow), so it doubles as the
|
||||
# network MAC connection.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)},
|
||||
name=NAME,
|
||||
model=self.device.model,
|
||||
manufacturer=MANUFACTURER,
|
||||
|
||||
@@ -10,11 +10,6 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"location": {
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -20,7 +20,7 @@ class LaMetricEntity(CoordinatorEntity[LaMetricDataUpdateCoordinator]):
|
||||
def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None:
|
||||
"""Initialize the LaMetric entity."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
connections = {(CONNECTION_NETWORK_MAC, coordinator.data.wifi.mac)}
|
||||
connections = {(CONNECTION_NETWORK_MAC, format_mac(coordinator.data.wifi.mac))}
|
||||
if coordinator.data.bluetooth is not None:
|
||||
connections.add(
|
||||
(CONNECTION_BLUETOOTH, format_mac(coordinator.data.bluetooth.address))
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["linkplay"],
|
||||
"requirements": ["python-linkplay==0.2.14"],
|
||||
"requirements": ["python-linkplay==0.2.12"],
|
||||
"zeroconf": ["_linkplay._tcp.local."]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user