Compare commits

..

2 Commits

Author SHA1 Message Date
jbouwh b1274426bd Follow up on comment 2026-06-15 19:13:36 +00:00
jbouwh d43c6e6991 Refactor MQTT config entry 2026-06-15 18:02:57 +00:00
416 changed files with 2296 additions and 15207 deletions
+1 -3
View File
@@ -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
View File
@@ -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:
+3 -65
View File
@@ -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
+1 -1
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@
"microsoft_face_identify",
"microsoft_face",
"microsoft",
"msteams",
"onedrive",
"onedrive_for_business",
"xbox"
+1 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -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"]
}
+2 -19
View File
@@ -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"]
}
+34 -54
View File
@@ -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%]",
+1 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -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*",
+1 -8
View File
@@ -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"
},
+6 -2
View File
@@ -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",
}
+2 -12
View File
@@ -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",
+8 -2
View File
@@ -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%]"
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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%]"
}
}
}
+1 -2
View File
@@ -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."
+1 -3
View File
@@ -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
+1 -6
View File
@@ -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.",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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."]
}
+1 -1
View File
@@ -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%]",
+1 -1
View File
@@ -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