mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
Merge branch 'dev' into add-librenms-integration
This commit is contained in:
@@ -8,7 +8,7 @@ description: Reviews Home Assistant GitHub pull requests and provides feedback c
|
||||
## Instructions:
|
||||
- Use 'gh pr view' to get the PR details and description.
|
||||
- Use 'gh pr diff' to see all the changes in the PR.
|
||||
- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions.
|
||||
- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. Explicitly pass the PR's target/base branch to the `ha-review` skill (obtained via `gh pr view`) so it diffs against the correct base.
|
||||
- Run a subagent in parallel to check the PR review comments following the `ha-pr-comment-audit` skill.
|
||||
|
||||
## IMPORTANT:
|
||||
|
||||
@@ -5,6 +5,9 @@ description: Reviews Home Assistant code changes and provides constructive feedb
|
||||
|
||||
# Review Code Changes
|
||||
|
||||
## Scope:
|
||||
- Unless instructed otherwise, review the full branch changes against the target branch. Resolve the base to an available ref (prefer `upstream/<base>`, then `origin/<base>`, then local `<base>`) and review `git diff "$(git merge-base "$BASE_REF" HEAD)"..HEAD`; use `dev` as the default base.
|
||||
|
||||
## Analyze the code changes for:
|
||||
- Code quality and style consistency
|
||||
- Potential bugs or issues
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -9,9 +9,6 @@ on:
|
||||
default: "dev"
|
||||
required: true
|
||||
|
||||
env:
|
||||
STARTUP_TIMEOUT_SECONDS: 300
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
@@ -33,62 +30,65 @@ jobs:
|
||||
- arch: aarch64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
env:
|
||||
IMAGE: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }}
|
||||
BASE_URL: http://localhost:8123
|
||||
CURL_OPTS: --silent --max-time 10
|
||||
services:
|
||||
homeassistant:
|
||||
image: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images]
|
||||
ports:
|
||||
- 8123:8123
|
||||
# Gate steps until Home Assistant answers (60 x 5s ≈ 300s startup budget)
|
||||
options: >-
|
||||
--health-cmd="curl --fail --silent --max-time 10 --output /dev/null http://127.0.0.1:8123/"
|
||||
--health-start-period=10s
|
||||
--health-interval=5s
|
||||
--health-retries=60
|
||||
steps:
|
||||
- name: Pull image
|
||||
id: pull
|
||||
run: |
|
||||
docker pull "$IMAGE"
|
||||
docker image inspect -f 'Testing {{index .RepoDigests 0}} ({{.Os}}/{{.Architecture}}), created {{.Created}}' "$IMAGE"
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Start container
|
||||
run: |
|
||||
docker run -d --name homeassistant -p 8123:8123 "$IMAGE"
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
package_json_file: tests/e2e/package.json
|
||||
|
||||
- name: Wait for Home Assistant to start
|
||||
run: |
|
||||
timeout=$((SECONDS + STARTUP_TIMEOUT_SECONDS))
|
||||
while ! curl $CURL_OPTS --fail --output /dev/null "$BASE_URL/"; do
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then
|
||||
echo "::error::Container exited before Home Assistant started"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SECONDS" -ge "$timeout" ]; then
|
||||
echo "::error::Home Assistant did not respond on port 8123 within ${STARTUP_TIMEOUT_SECONDS}s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: tests/e2e/pnpm-lock.yaml
|
||||
|
||||
- name: Check frontend is served
|
||||
run: |
|
||||
# Pre-onboarding, / redirects to /onboarding.html; --location follows it
|
||||
status=$(curl $CURL_OPTS --location --output /dev/null --write-out '%{http_code}' "$BASE_URL/")
|
||||
if [ "$status" -ne 200 ]; then
|
||||
echo "::error::Expected HTTP 200 from frontend, got $status"
|
||||
exit 1
|
||||
fi
|
||||
- name: Install E2E test dependencies
|
||||
working-directory: tests/e2e
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check onboarding API responds
|
||||
run: |
|
||||
curl $CURL_OPTS --fail "$BASE_URL/api/onboarding" \
|
||||
| jq -e 'type == "array" and length > 0'
|
||||
- name: Install Playwright browser
|
||||
working-directory: tests/e2e
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Check container is still running
|
||||
run: |
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then
|
||||
echo "::error::Container is no longer running after checks"
|
||||
exit 1
|
||||
fi
|
||||
- name: Run Playwright E2E tests
|
||||
working-directory: tests/e2e
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-${{ matrix.arch }}
|
||||
path: |
|
||||
tests/e2e/playwright-report/
|
||||
tests/e2e/test-results/
|
||||
|
||||
- name: Dump container logs
|
||||
if: always() && steps.pull.outcome == 'success'
|
||||
run: docker logs homeassistant > homeassistant.log 2>&1 || true
|
||||
if: always()
|
||||
env:
|
||||
CONTAINER: ${{ job.services.homeassistant.id }}
|
||||
run: docker logs "$CONTAINER" > homeassistant.log 2>&1 || true
|
||||
|
||||
- name: Upload container logs
|
||||
if: always() && steps.pull.outcome == 'success'
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: container-logs-${{ matrix.arch }}
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
- name: 60 days stale PRs policy and 90 days stale issue policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
remove-stale-when-updated: true
|
||||
|
||||
@@ -145,3 +145,7 @@ pytest_buckets.txt
|
||||
.claude/worktrees/
|
||||
.serena/
|
||||
|
||||
# Playwright e2e tests
|
||||
tests/e2e/node_modules/
|
||||
tests/e2e/playwright-report/
|
||||
tests/e2e/test-results/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.20
|
||||
rev: v0.15.21
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -5,3 +5,4 @@ homeassistant/generated/*
|
||||
tests/components/lidarr/fixtures/initialize.js
|
||||
tests/components/lidarr/fixtures/initialize-wrong.js
|
||||
tests/fixtures/core/config/yaml_errors/
|
||||
tests/e2e/pnpm-lock.yaml
|
||||
|
||||
+3
-1
@@ -228,6 +228,7 @@ homeassistant.components.fujitsu_fglair.*
|
||||
homeassistant.components.fully_kiosk.*
|
||||
homeassistant.components.fumis.*
|
||||
homeassistant.components.fyta.*
|
||||
homeassistant.components.gatus.*
|
||||
homeassistant.components.generic_hygrostat.*
|
||||
homeassistant.components.generic_thermostat.*
|
||||
homeassistant.components.geo_location.*
|
||||
@@ -337,6 +338,7 @@ homeassistant.components.lawn_mower.*
|
||||
homeassistant.components.lcn.*
|
||||
homeassistant.components.ld2410_ble.*
|
||||
homeassistant.components.led_ble.*
|
||||
homeassistant.components.led_infrared.*
|
||||
homeassistant.components.lektrico.*
|
||||
homeassistant.components.letpot.*
|
||||
homeassistant.components.lg_infrared.*
|
||||
@@ -349,6 +351,7 @@ homeassistant.components.lifx.*
|
||||
homeassistant.components.light.*
|
||||
homeassistant.components.linkplay.*
|
||||
homeassistant.components.litejet.*
|
||||
homeassistant.components.litellm.*
|
||||
homeassistant.components.litterrobot.*
|
||||
homeassistant.components.llama_cpp.*
|
||||
homeassistant.components.local_ip.*
|
||||
@@ -382,7 +385,6 @@ homeassistant.components.min_max.*
|
||||
homeassistant.components.minecraft_server.*
|
||||
homeassistant.components.mjpeg.*
|
||||
homeassistant.components.modbus.*
|
||||
homeassistant.components.modbus_connection.*
|
||||
homeassistant.components.modem_callerid.*
|
||||
homeassistant.components.mold_indicator.*
|
||||
homeassistant.components.monzo.*
|
||||
|
||||
Generated
+12
-4
@@ -625,6 +625,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/gardena_bluetooth/ @elupus
|
||||
/homeassistant/components/gate/ @home-assistant/core
|
||||
/tests/components/gate/ @home-assistant/core
|
||||
/homeassistant/components/gatus/ @TN-1
|
||||
/tests/components/gatus/ @TN-1
|
||||
/homeassistant/components/gdacs/ @exxamalte
|
||||
/tests/components/gdacs/ @exxamalte
|
||||
/homeassistant/components/generic/ @davet2001
|
||||
@@ -717,6 +719,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/habitica/ @tr4nt0r
|
||||
/homeassistant/components/hanna/ @bestycame
|
||||
/tests/components/hanna/ @bestycame
|
||||
/homeassistant/components/harbor/ @Lash-L @afgarcia86
|
||||
/tests/components/harbor/ @Lash-L @afgarcia86
|
||||
/homeassistant/components/hardkernel/ @home-assistant/core
|
||||
/tests/components/hardkernel/ @home-assistant/core
|
||||
/homeassistant/components/hardware/ @home-assistant/core
|
||||
@@ -1001,6 +1005,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/leaone/ @bdraco
|
||||
/homeassistant/components/led_ble/ @bdraco
|
||||
/tests/components/led_ble/ @bdraco
|
||||
/homeassistant/components/led_infrared/ @tr4nt0r
|
||||
/tests/components/led_infrared/ @tr4nt0r
|
||||
/homeassistant/components/lektrico/ @lektrico
|
||||
/tests/components/lektrico/ @lektrico
|
||||
/homeassistant/components/letpot/ @jpelgrom
|
||||
@@ -1032,6 +1038,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/linux_battery/ @fabaff
|
||||
/homeassistant/components/litejet/ @joncar
|
||||
/tests/components/litejet/ @joncar
|
||||
/homeassistant/components/litellm/ @luismalves
|
||||
/tests/components/litellm/ @luismalves
|
||||
/homeassistant/components/litterrobot/ @natekspencer @tkdrob
|
||||
/tests/components/litterrobot/ @natekspencer @tkdrob
|
||||
/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet
|
||||
@@ -1146,8 +1154,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/moat/ @bdraco
|
||||
/homeassistant/components/mobile_app/ @home-assistant/core
|
||||
/tests/components/mobile_app/ @home-assistant/core
|
||||
/homeassistant/components/modbus_connection/ @home-assistant/core
|
||||
/tests/components/modbus_connection/ @home-assistant/core
|
||||
/homeassistant/components/modem_callerid/ @tkdrob
|
||||
/tests/components/modem_callerid/ @tkdrob
|
||||
/homeassistant/components/modern_forms/ @wonderslug
|
||||
@@ -1715,8 +1721,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/sonarr/ @ctalkington
|
||||
/homeassistant/components/songpal/ @rytilahti @shenxn
|
||||
/tests/components/songpal/ @rytilahti @shenxn
|
||||
/homeassistant/components/sonos/ @jjlawren @peterager
|
||||
/tests/components/sonos/ @jjlawren @peterager
|
||||
/homeassistant/components/sonos/ @peterager @jjlawren
|
||||
/tests/components/sonos/ @peterager @jjlawren
|
||||
/homeassistant/components/soundtouch/ @kroimon
|
||||
/tests/components/soundtouch/ @kroimon
|
||||
/homeassistant/components/spaceapi/ @fabaff
|
||||
@@ -1965,6 +1971,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/version/ @ludeeus
|
||||
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
|
||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
|
||||
/homeassistant/components/vibration/ @home-assistant/core
|
||||
/tests/components/vibration/ @home-assistant/core
|
||||
/homeassistant/components/vicare/ @CFenner @lackas
|
||||
/tests/components/vicare/ @CFenner @lackas
|
||||
/homeassistant/components/victron_ble/ @rajlaud
|
||||
|
||||
@@ -264,6 +264,7 @@ DEFAULT_INTEGRATIONS = {
|
||||
"occupancy",
|
||||
"power",
|
||||
"temperature",
|
||||
"vibration",
|
||||
"window",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
|
||||
@@ -163,6 +163,4 @@ class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]):
|
||||
):
|
||||
if not set(device.identifiers) & identifiers:
|
||||
_LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||
device_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
device_reg.async_remove_device(device.id)
|
||||
|
||||
@@ -38,11 +38,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
|
||||
|
||||
lat = data[CONF_LATITUDE]
|
||||
lng = data[CONF_LONGITUDE]
|
||||
distance = data[CONF_RADIUS]
|
||||
|
||||
# Check that the provided latitude/longitude provide a response
|
||||
try:
|
||||
test_data = await client.observations.latLong(lat, lng, distance=distance)
|
||||
test_data = await client.observations.latLong(lat, lng)
|
||||
|
||||
except InvalidKeyError as exc:
|
||||
raise InvalidAuth from exc
|
||||
|
||||
@@ -77,7 +77,6 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
obs = await self.airnow.observations.latLong(
|
||||
self.latitude,
|
||||
self.longitude,
|
||||
distance=self.distance,
|
||||
)
|
||||
|
||||
except (AirNowError, ClientConnectorError, InvalidJsonError) as error:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyairnow"],
|
||||
"requirements": ["pyairnow==1.3.1"]
|
||||
"requirements": ["pyairnow==1.4.0"]
|
||||
}
|
||||
|
||||
@@ -4,11 +4,7 @@ from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, override
|
||||
|
||||
from pyairobotrest.exceptions import (
|
||||
AirobotConnectionError,
|
||||
AirobotError,
|
||||
AirobotTimeoutError,
|
||||
)
|
||||
from pyairobotrest.exceptions import AirobotError
|
||||
|
||||
from homeassistant.components.button import (
|
||||
ButtonDeviceClass,
|
||||
@@ -32,7 +28,6 @@ class AirobotButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Airobot button entity."""
|
||||
|
||||
press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
|
||||
ignore_connection_errors: bool = False
|
||||
|
||||
|
||||
BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
|
||||
@@ -41,7 +36,6 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
|
||||
ignore_connection_errors=True,
|
||||
),
|
||||
AirobotButtonEntityDescription(
|
||||
key="recalibrate_co2",
|
||||
@@ -86,14 +80,6 @@ class AirobotButton(AirobotEntity, ButtonEntity):
|
||||
"""Handle the button press."""
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator)
|
||||
except (AirobotConnectionError, AirobotTimeoutError) as err:
|
||||
if not self.entity_description.ignore_connection_errors:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="button_press_failed",
|
||||
translation_placeholders={"button": self.entity_description.key},
|
||||
) from err
|
||||
# Connection errors during reboot are expected as device restarts
|
||||
except AirobotError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyairobotrest==0.3.0"]
|
||||
"requirements": ["pyairobotrest==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -111,6 +111,4 @@ def remove_stale_devices(
|
||||
break
|
||||
|
||||
if device_id and device_id not in all_device_ids:
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
device_registry.async_remove_device(device_entry.id)
|
||||
|
||||
@@ -106,7 +106,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# 1 -> 2: Unique ID format changed, so delete and re-import:
|
||||
if version == 1:
|
||||
dev_reg = dr.async_get(hass)
|
||||
dev_reg.async_clear_config_entry(entry.entry_id)
|
||||
dev_reg.async_clear_config_entry(entry.entry_id, entry.domain)
|
||||
|
||||
en_reg = er.async_get(hass)
|
||||
en_reg.async_clear_config_entry(entry.entry_id)
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
import attr
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.components.diagnostics import (
|
||||
async_redact_data,
|
||||
device_entry_as_dict,
|
||||
entity_entry_as_dict,
|
||||
)
|
||||
from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
@@ -40,7 +42,7 @@ async def async_get_config_entry_diagnostics(
|
||||
return data
|
||||
|
||||
data["device"] = {
|
||||
**async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV),
|
||||
**async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV),
|
||||
"entities": {},
|
||||
}
|
||||
|
||||
@@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics(
|
||||
# The context doesn't provide useful information in this case.
|
||||
state_dict.pop("context", None)
|
||||
|
||||
entity_dict = entity_entry_as_dict(entity_entry)
|
||||
# The entity_id is already provided at root level (the key).
|
||||
del entity_dict["entity_id"]
|
||||
data["device"]["entities"][entity_entry.entity_id] = {
|
||||
**async_redact_data(
|
||||
attr.asdict(
|
||||
entity_entry, filter=lambda attr, value: attr.name != "entity_id"
|
||||
),
|
||||
TO_REDACT,
|
||||
),
|
||||
**async_redact_data(entity_dict, TO_REDACT),
|
||||
"state": state_dict,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers import (
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||
|
||||
from .const import CONF_CHAT_MODEL, DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
|
||||
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
|
||||
@@ -137,7 +137,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
# Device and entity registries will set the disabled_by flag to None
|
||||
# when moving a device or entity disabled by CONFIG_ENTRY to an enabled
|
||||
# config entry, but we want to set it to USER instead,
|
||||
device_disabled_by = device.disabled_by
|
||||
device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED
|
||||
if (
|
||||
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
@@ -147,20 +147,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
device.id,
|
||||
disabled_by=device_disabled_by,
|
||||
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
add_config_subentry_id=subentry.subentry_id,
|
||||
add_config_entry_id=parent_entry.entry_id,
|
||||
new_config_entry_id=parent_entry.entry_id,
|
||||
new_config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
if parent_entry.entry_id != entry.entry_id:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
)
|
||||
else:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
if not use_existing:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["anthropic==0.108.0"]
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
import attr
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.components.diagnostics import (
|
||||
async_redact_data,
|
||||
device_entry_as_dict,
|
||||
entity_entry_as_dict,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
ATTR_IDENTIFIERS,
|
||||
@@ -39,7 +41,7 @@ async def async_get_config_entry_diagnostics(
|
||||
return data
|
||||
|
||||
data["device"] = {
|
||||
**async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV),
|
||||
**async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV),
|
||||
"entities": {},
|
||||
"tracked_devices": [],
|
||||
}
|
||||
@@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics(
|
||||
# The context doesn't provide useful information in this case.
|
||||
state_dict.pop("context", None)
|
||||
|
||||
entity_dict = entity_entry_as_dict(entity_entry)
|
||||
# The entity_id is already provided at root level (the key).
|
||||
del entity_dict["entity_id"]
|
||||
data["device"]["entities"][entity_entry.entity_id] = {
|
||||
**async_redact_data(
|
||||
attr.asdict(
|
||||
entity_entry, filter=lambda attr, value: attr.name != "entity_id"
|
||||
),
|
||||
TO_REDACT,
|
||||
),
|
||||
**async_redact_data(entity_dict, TO_REDACT),
|
||||
"state": state_dict,
|
||||
}
|
||||
|
||||
|
||||
@@ -123,8 +123,4 @@ rules:
|
||||
comment: |
|
||||
The b2sdk library does not support custom HTTP session injection.
|
||||
It manages HTTP connections internally through its own session management.
|
||||
strict-typing:
|
||||
status: exempt
|
||||
comment: |
|
||||
The b2sdk dependency does not include a py.typed file and is not PEP 561 compliant.
|
||||
This is outside the integration's control as it's a third-party library requirement.
|
||||
strict-typing: todo
|
||||
|
||||
@@ -62,9 +62,7 @@ async def async_setup_entry(
|
||||
if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in {
|
||||
remote.serial_number for remote in remotes
|
||||
}:
|
||||
device_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
device_registry.async_remove_device(device.id)
|
||||
|
||||
async_add_entities(new_entities=entities)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ BINARY_SENSOR_TYPES = (
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="input",
|
||||
translation_key="input",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -43,8 +43,6 @@ async def async_setup_entry(
|
||||
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
|
||||
"""Representation of BleBox buttons."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
|
||||
) -> None:
|
||||
|
||||
@@ -70,6 +70,16 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"input": { "name": "Input" }
|
||||
},
|
||||
"button": {
|
||||
"close": { "name": "Close" },
|
||||
"down": { "name": "Down" },
|
||||
"fav": { "name": "Favorite" },
|
||||
"open": { "name": "Open" },
|
||||
"up": { "name": "Up" }
|
||||
},
|
||||
"light": { "channel": { "name": "Channel {index}" } },
|
||||
"sensor": {
|
||||
"active_power": { "name": "Active power" },
|
||||
|
||||
@@ -22,7 +22,7 @@ from .const import DOMAIN
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(hours=1)
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ from .const import (
|
||||
)
|
||||
from .manager import HomeAssistantBluetoothManager
|
||||
from .match import BluetoothCallbackMatcher, IntegrationMatcher
|
||||
from .models import BluetoothCallback, BluetoothChange
|
||||
from .models import BluetoothCallback, BluetoothCallbackReplay, BluetoothChange
|
||||
from .storage import BluetoothStorage
|
||||
from .util import adapter_title, resolve_scanning_mode
|
||||
|
||||
@@ -109,6 +109,7 @@ __all__ = [
|
||||
"BaseHaScanner",
|
||||
"BluetoothCallback",
|
||||
"BluetoothCallbackMatcher",
|
||||
"BluetoothCallbackReplay",
|
||||
"BluetoothChange",
|
||||
"BluetoothReachabilityIntent",
|
||||
"BluetoothScannerDevice",
|
||||
|
||||
@@ -26,7 +26,12 @@ from homeassistant.helpers.singleton import singleton
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import HomeAssistantBluetoothManager
|
||||
from .match import BluetoothCallbackMatcher
|
||||
from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback
|
||||
from .models import (
|
||||
BluetoothCallback,
|
||||
BluetoothCallbackReplay,
|
||||
BluetoothChange,
|
||||
ProcessAdvertisementCallback,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak.backends.device import BLEDevice
|
||||
@@ -143,6 +148,7 @@ def async_register_callback(
|
||||
*,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
replay: BluetoothCallbackReplay = BluetoothCallbackReplay.OLDEST_FIRST,
|
||||
) -> Callable[[], None]:
|
||||
"""Register to receive a callback on bluetooth change.
|
||||
|
||||
@@ -155,10 +161,13 @@ def async_register_callback(
|
||||
values. Without an address in the matcher the active-scan request
|
||||
is skipped; the callback itself still fires normally.
|
||||
|
||||
``replay`` controls which cached advertisements are replayed to the
|
||||
callback on registration; defaults to OLDEST_FIRST.
|
||||
|
||||
Returns a callback that can be used to cancel the registration.
|
||||
"""
|
||||
return _get_manager(hass).async_register_callback(
|
||||
callback, match_dict, mode, scan_interval, scan_duration
|
||||
callback, match_dict, mode, scan_interval, scan_duration, replay
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""The bluetooth integration."""
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
import itertools
|
||||
import logging
|
||||
from operator import attrgetter
|
||||
from typing import override
|
||||
|
||||
from bleak_retry_connector import BleakSlotManager
|
||||
@@ -54,7 +55,12 @@ from .match import (
|
||||
IntegrationMatcher,
|
||||
ble_device_matches,
|
||||
)
|
||||
from .models import BluetoothCallback, BluetoothChange, BluetoothServiceInfoBleak
|
||||
from .models import (
|
||||
BluetoothCallback,
|
||||
BluetoothCallbackReplay,
|
||||
BluetoothChange,
|
||||
BluetoothServiceInfoBleak,
|
||||
)
|
||||
from .storage import BluetoothStorage
|
||||
from .util import async_load_history_from_system
|
||||
|
||||
@@ -212,6 +218,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
mode: BluetoothScanningMode = BluetoothScanningMode.ACTIVE,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
replay: BluetoothCallbackReplay = BluetoothCallbackReplay.OLDEST_FIRST,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback."""
|
||||
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
|
||||
@@ -245,16 +252,37 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
if cancel_active_scan is not None:
|
||||
cancel_active_scan()
|
||||
|
||||
if replay is not BluetoothCallbackReplay.DISABLED:
|
||||
self._async_replay_history_for_callback(
|
||||
connectable, callback, callback_matcher, replay
|
||||
)
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
def _async_replay_history_for_callback(
|
||||
self,
|
||||
connectable: bool,
|
||||
callback: BluetoothCallback,
|
||||
callback_matcher: BluetoothCallbackMatcherWithCallback,
|
||||
replay: BluetoothCallbackReplay,
|
||||
) -> None:
|
||||
"""Replay history for a callback."""
|
||||
# If we have history for the subscriber, we can trigger the callback
|
||||
# immediately with the last packet so the subscriber can see the
|
||||
# device.
|
||||
history = self._connectable_history if connectable else self._all_history
|
||||
service_infos: Iterable[BluetoothServiceInfoBleak] = []
|
||||
service_infos: list[BluetoothServiceInfoBleak] = []
|
||||
if (address := callback_matcher.get(ADDRESS)) is not None:
|
||||
if service_info := history.get(address):
|
||||
service_infos = [service_info]
|
||||
else:
|
||||
service_infos = history.values()
|
||||
# Sort by time explicitly; dict insertion order is not guaranteed
|
||||
# to match advertisement time after history is loaded from storage.
|
||||
service_infos = sorted(
|
||||
history.values(),
|
||||
key=attrgetter("time"),
|
||||
reverse=replay is BluetoothCallbackReplay.NEWEST_FIRST,
|
||||
)
|
||||
|
||||
for service_info in service_infos:
|
||||
if ble_device_matches(callback_matcher, service_info):
|
||||
@@ -263,8 +291,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
except Exception:
|
||||
_LOGGER.exception("Error in bluetooth callback")
|
||||
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
@override
|
||||
def async_stop(self, event: Event | None = None) -> None:
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"""Models for bluetooth."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from enum import Enum, auto
|
||||
|
||||
from home_assistant_bluetooth import BluetoothServiceInfoBleak
|
||||
|
||||
BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT")
|
||||
type BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None]
|
||||
type ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool]
|
||||
|
||||
|
||||
class BluetoothCallbackReplay(Enum):
|
||||
"""Controls how history is replayed when a callback is registered."""
|
||||
|
||||
OLDEST_FIRST = auto()
|
||||
NEWEST_FIRST = auto()
|
||||
DISABLED = auto()
|
||||
|
||||
@@ -176,9 +176,7 @@ class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]):
|
||||
):
|
||||
if not set(device.identifiers) & identifiers:
|
||||
_LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||
device_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
device_reg.async_remove_device(device.id)
|
||||
|
||||
|
||||
class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Data update coordinator for caldav."""
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from functools import partial
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, override
|
||||
@@ -54,15 +53,17 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
) -> list[CalendarEvent]:
|
||||
"""Get all events in a specific time frame."""
|
||||
# Get event list from the current calendar
|
||||
vevent_list = await hass.async_add_executor_job(
|
||||
partial(
|
||||
self.calendar.search,
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
event=True,
|
||||
expand=True,
|
||||
)
|
||||
return await hass.async_add_executor_job(self._get_events, start_date, end_date)
|
||||
|
||||
def _get_events(
|
||||
self, start_date: datetime, end_date: datetime
|
||||
) -> list[CalendarEvent]:
|
||||
"""Fetch and parse events in a specific time frame."""
|
||||
vevent_list = self.calendar.search(
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
event=True,
|
||||
expand=True,
|
||||
)
|
||||
event_list = []
|
||||
for event in vevent_list:
|
||||
@@ -96,16 +97,23 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
||||
start_of_today = dt_util.start_of_local_day()
|
||||
start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days)
|
||||
|
||||
event, offset = await self.hass.async_add_executor_job(
|
||||
self._get_next_event, start_of_today, start_of_tomorrow
|
||||
)
|
||||
self.offset = offset
|
||||
return event
|
||||
|
||||
def _get_next_event(
|
||||
self, start_of_today: datetime, start_of_tomorrow: datetime
|
||||
) -> tuple[CalendarEvent | None, timedelta | None]:
|
||||
"""Fetch and parse the next matching event."""
|
||||
# We have to retrieve the results for the whole day as the server
|
||||
# won't return events that have already started
|
||||
results = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
self.calendar.search,
|
||||
start=start_of_today,
|
||||
end=start_of_tomorrow,
|
||||
event=True,
|
||||
expand=True,
|
||||
),
|
||||
results = self.calendar.search(
|
||||
start=start_of_today,
|
||||
end=start_of_tomorrow,
|
||||
event=True,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
# Create new events for each recurrence of an event that happens today.
|
||||
@@ -168,15 +176,13 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
||||
len(vevents),
|
||||
self.calendar.name,
|
||||
)
|
||||
self.offset = None
|
||||
return None
|
||||
return None, None
|
||||
|
||||
# Populate the entity attributes with the event values
|
||||
(summary, offset) = extract_offset(
|
||||
get_attr_value(vevent, "summary") or "", OFFSET
|
||||
)
|
||||
self.offset = offset
|
||||
return CalendarEvent(
|
||||
next_event = CalendarEvent(
|
||||
summary=summary,
|
||||
start=self.to_local(vevent.dtstart.value),
|
||||
end=self.to_local(self.get_end_date(vevent)),
|
||||
@@ -189,6 +195,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
||||
else None
|
||||
),
|
||||
)
|
||||
return next_event, offset
|
||||
|
||||
@staticmethod
|
||||
def is_matching(vevent, search):
|
||||
|
||||
@@ -60,6 +60,16 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
def _get_todo_items(calendar: caldav.Calendar) -> list[TodoItem]:
|
||||
"""Fetch and parse todo items."""
|
||||
results = calendar.search(todo=True, include_completed=True)
|
||||
return [
|
||||
todo_item
|
||||
for resource in results
|
||||
if (todo_item := _todo_item(resource)) is not None
|
||||
]
|
||||
|
||||
|
||||
def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
|
||||
"""Convert a caldav Todo into a TodoItem."""
|
||||
if (
|
||||
@@ -108,18 +118,9 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update To-do list entity state."""
|
||||
results = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
self._calendar.search,
|
||||
todo=True,
|
||||
include_completed=True,
|
||||
)
|
||||
self._attr_todo_items = await self.hass.async_add_executor_job(
|
||||
_get_todo_items, self._calendar
|
||||
)
|
||||
self._attr_todo_items = [
|
||||
todo_item
|
||||
for resource in results
|
||||
if (todo_item := _todo_item(resource)) is not None
|
||||
]
|
||||
|
||||
@override
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydaikin"],
|
||||
"requirements": ["pydaikin==2.18.1"],
|
||||
"requirements": ["pydaikin==2.18.2"],
|
||||
"zeroconf": ["_dkapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Media player platform for the Denon RS-232 integration."""
|
||||
|
||||
from typing import Literal, cast, override
|
||||
import re
|
||||
from typing import Any, Literal, cast, override
|
||||
|
||||
from denon_rs232 import (
|
||||
MIN_VOLUME_DB,
|
||||
@@ -13,13 +14,17 @@ from denon_rs232 import (
|
||||
)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseError,
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -71,6 +76,25 @@ INPUT_SOURCE_DENON_TO_HA: dict[InputSource, str] = {
|
||||
InputSource.DAB: "dab",
|
||||
}
|
||||
|
||||
TUNER_PRESETS_ROOT = "presets"
|
||||
TUNER_FREQUENCY_MIN = 8750
|
||||
TUNER_FREQUENCY_MAX = 10800
|
||||
TUNER_FREQUENCY_LENGTH = 6
|
||||
#: Reported frequencies at or above this value are AM, which is not supported.
|
||||
TUNER_FREQUENCY_FM_MAX = 50000
|
||||
|
||||
|
||||
def _tuner_frequency_to_mhz(frequency: str | None) -> str | None:
|
||||
"""Convert a reported tuner frequency to MHz, or None if it is not FM."""
|
||||
if frequency is None or not frequency.isdigit():
|
||||
return None
|
||||
|
||||
value = int(frequency)
|
||||
if value >= TUNER_FREQUENCY_FM_MAX:
|
||||
return None
|
||||
|
||||
return f"{value / 100:.2f}"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -138,7 +162,11 @@ class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
|
||||
if zone == "main":
|
||||
self._attr_name = None
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
self._attr_supported_features |= (
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
)
|
||||
else:
|
||||
self._attr_name = "Zone 2" if zone == "zone_2" else "Zone 3"
|
||||
|
||||
@@ -172,6 +200,13 @@ class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
source = self._player.input_source
|
||||
self._attr_source = INPUT_SOURCE_DENON_TO_HA.get(source) if source else None
|
||||
|
||||
if source is InputSource.TUNER:
|
||||
self._attr_media_channel = _tuner_frequency_to_mhz(
|
||||
self._receiver.state.main_zone.tuner_frequency
|
||||
)
|
||||
else:
|
||||
self._attr_media_channel = None
|
||||
|
||||
volume_min = self._player.volume_min
|
||||
volume_max = self._player.volume_max
|
||||
if volume_min is not None:
|
||||
@@ -239,3 +274,62 @@ class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
raise HomeAssistantError("Invalid source")
|
||||
|
||||
await self._player.select_input_source(input_source)
|
||||
|
||||
@override
|
||||
async def async_play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Tune to a tuner preset or an FM frequency."""
|
||||
if media_type != MediaType.CHANNEL:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_media_type",
|
||||
translation_placeholders={"media_type": str(media_type)},
|
||||
)
|
||||
|
||||
player = cast(MainPlayer, self._player)
|
||||
if re.fullmatch(r"[A-G][1-8]", media_id):
|
||||
await player.set_tuner_preset(media_id)
|
||||
elif (match := re.fullmatch(r"0*([0-9]{1,5})", media_id)) and (
|
||||
TUNER_FREQUENCY_MIN <= (frequency := int(match[1])) <= TUNER_FREQUENCY_MAX
|
||||
):
|
||||
await player.set_tuner_frequency(f"{frequency:0{TUNER_FREQUENCY_LENGTH}d}")
|
||||
else:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_tuner_channel",
|
||||
translation_placeholders={"media_id": media_id},
|
||||
)
|
||||
|
||||
@override
|
||||
async def async_browse_media(
|
||||
self,
|
||||
media_content_type: MediaType | str | None = None,
|
||||
media_content_id: str | None = None,
|
||||
) -> BrowseMedia:
|
||||
"""List the tuner presets as playable channels."""
|
||||
if media_content_id not in (None, TUNER_PRESETS_ROOT):
|
||||
raise BrowseError(f"Media not found: {media_content_id}")
|
||||
|
||||
return BrowseMedia(
|
||||
title="Tuner presets",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id=TUNER_PRESETS_ROOT,
|
||||
media_content_type=MediaType.CHANNELS,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.CHANNEL,
|
||||
children=[
|
||||
BrowseMedia(
|
||||
title=preset,
|
||||
media_class=MediaClass.CHANNEL,
|
||||
media_content_id=preset,
|
||||
media_content_type=MediaType.CHANNEL,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
)
|
||||
for preset in (
|
||||
f"{bank}{number}" for bank in "ABCDEFG" for number in range(1, 9)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_tuner_channel": {
|
||||
"message": "{media_id} is not a valid tuner preset (A1-G8) or FM frequency in hundredths of MHz (8750-10800; for example, 9930 for 99.30 MHz)."
|
||||
},
|
||||
"unsupported_media_type": {
|
||||
"message": "Cannot play media of type {media_type}. Only tuner channels are supported."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"model": {
|
||||
"options": {
|
||||
|
||||
@@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry.async_on_unload(
|
||||
async_handle_source_entity_changes(
|
||||
hass,
|
||||
add_helper_config_entry_to_device=False,
|
||||
helper_config_entry_id=entry.entry_id,
|
||||
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
|
||||
source_device_id=async_entity_id_to_device_id(
|
||||
|
||||
@@ -43,6 +43,32 @@ ENTITY_PLATFORMS = {
|
||||
}
|
||||
|
||||
|
||||
def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str:
|
||||
"""Resolve a device automation device id, following a composite device id.
|
||||
|
||||
A device automation created when a device could be connected to more than one
|
||||
config entry stores the id of the (now removed) composite device. When the
|
||||
automation's domain owns one of the split devices' config entries, resolve to that
|
||||
device - an integration may look the device up in its own registry, which only
|
||||
knows the current device id, not the removed composite id.
|
||||
"""
|
||||
device_registry = dr.async_get(hass)
|
||||
if device_id in device_registry.devices:
|
||||
return device_id
|
||||
if not (
|
||||
split_devices := device_registry.async_get_devices_for_composite_device_id(
|
||||
device_id
|
||||
)
|
||||
):
|
||||
return device_id
|
||||
# Resolve to the device owned by a config entry of the automation's domain
|
||||
for split_device in split_devices:
|
||||
entry = hass.config_entries.async_get_entry(split_device.config_entry_id)
|
||||
if entry is not None and entry.domain == domain:
|
||||
return split_device.id
|
||||
return device_id
|
||||
|
||||
|
||||
async def async_validate_device_automation_config(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -51,6 +77,17 @@ async def async_validate_device_automation_config(
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
validated_config: ConfigType = automation_schema(config)
|
||||
|
||||
# A device automation may reference a pre-migration composite device id; resolve it
|
||||
# to the split device for its domain so the device and its entities exist and the
|
||||
# integration platform (validation and attach/call) receives a live device id
|
||||
resolved_device_id = _resolve_device_id(
|
||||
hass, validated_config[CONF_DEVICE_ID], validated_config[CONF_DOMAIN]
|
||||
)
|
||||
if resolved_device_id != validated_config[CONF_DEVICE_ID]:
|
||||
config = {**config, CONF_DEVICE_ID: resolved_device_id}
|
||||
validated_config = {**validated_config, CONF_DEVICE_ID: resolved_device_id}
|
||||
|
||||
platform = await async_get_device_automation_platform(
|
||||
hass, validated_config[CONF_DOMAIN], automation_type
|
||||
)
|
||||
|
||||
@@ -707,17 +707,29 @@ class ScannerEntity(
|
||||
await super().async_internal_added_to_hass()
|
||||
return
|
||||
|
||||
# Attach entry to device
|
||||
if self.registry_entry.device_id != device_entry.id:
|
||||
self.registry_entry = er.async_get(self.hass).async_update_entity(
|
||||
self.entity_id, device_id=device_entry.id
|
||||
dev_reg = dr.async_get(self.hass)
|
||||
# find_device_entry may return a synthesized pre-migration composite whose id is
|
||||
# not a real device and can't be assigned to an entity; resolve it to the split
|
||||
# owned by this config entry so we attach to a concrete device.
|
||||
if device_entry.id not in dev_reg.devices:
|
||||
device_entry = next(
|
||||
(
|
||||
split
|
||||
for split in dev_reg.async_get_devices_for_composite_device_id(
|
||||
device_entry.id
|
||||
)
|
||||
if split.config_entry_id == self.platform.config_entry.entry_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# Attach device to config entry
|
||||
if self.platform.config_entry.entry_id not in device_entry.config_entries:
|
||||
dr.async_get(self.hass).async_update_device(
|
||||
device_entry.id,
|
||||
add_config_entry_id=self.platform.config_entry.entry_id,
|
||||
# Attach entry to device
|
||||
if (
|
||||
device_entry is not None
|
||||
and self.registry_entry.device_id != device_entry.id
|
||||
):
|
||||
self.registry_entry = er.async_get(self.hass).async_update_entity(
|
||||
self.entity_id, device_id=device_entry.id
|
||||
)
|
||||
|
||||
# Do this last or else the entity registry update listener has been installed
|
||||
|
||||
@@ -61,8 +61,13 @@ from homeassistant.loader import DHCPMatcher, async_get_dhcp
|
||||
|
||||
from . import websocket_api
|
||||
from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS
|
||||
from .helpers import async_discovered_service_info
|
||||
from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers
|
||||
|
||||
__all__ = [
|
||||
"async_discovered_service_info",
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
REGISTERED_DEVICES: Final = "registered_devices"
|
||||
|
||||
@@ -4,7 +4,9 @@ from collections.abc import Callable
|
||||
from functools import partial
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import HOSTNAME, IP_ADDRESS
|
||||
from .models import DATA_DHCP, DHCPAddressData
|
||||
|
||||
|
||||
@@ -33,3 +35,16 @@ def async_get_address_data_internal(
|
||||
This is not intended for use by integrations.
|
||||
"""
|
||||
return hass.data[DATA_DHCP].address_data
|
||||
|
||||
|
||||
@callback
|
||||
def async_discovered_service_info(hass: HomeAssistant) -> list[DhcpServiceInfo]:
|
||||
"""Return the discovered DHCP devices."""
|
||||
return [
|
||||
DhcpServiceInfo(
|
||||
ip=data[IP_ADDRESS],
|
||||
hostname=data[HOSTNAME].lower(),
|
||||
macaddress=mac_address,
|
||||
)
|
||||
for mac_address, data in async_get_address_data_internal(hass).items()
|
||||
]
|
||||
|
||||
@@ -36,9 +36,14 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import format_unserializable_data
|
||||
|
||||
from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType
|
||||
from .util import async_redact_data, entity_entry_as_dict
|
||||
from .util import async_redact_data, device_entry_as_dict, entity_entry_as_dict
|
||||
|
||||
__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"]
|
||||
__all__ = [
|
||||
"REDACTED",
|
||||
"async_redact_data",
|
||||
"device_entry_as_dict",
|
||||
"entity_entry_as_dict",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, cast, overload
|
||||
import attr
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
|
||||
from .const import REDACTED
|
||||
@@ -45,6 +46,33 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T:
|
||||
return cast(_T, redacted)
|
||||
|
||||
|
||||
# DeviceEntry attributes that are internal bookkeeping and must not be exposed in
|
||||
# diagnostics. Underscore attributes (_cache, _suggested_area, and the transient
|
||||
# _pending_move / _composite_subentries) are excluded separately by _device_entry_filter.
|
||||
# The composite-device migration attributes below can be removed in HA Core 2027.8.
|
||||
_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = (
|
||||
"composite_device_id",
|
||||
"composite_primary_config_entry",
|
||||
"has_composite_identifiers",
|
||||
"split_at",
|
||||
)
|
||||
|
||||
|
||||
def _device_entry_filter(a: attr.Attribute, _: Any) -> bool:
|
||||
return (
|
||||
not a.name.startswith("_") and a.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def device_entry_as_dict(entry: DeviceEntry) -> dict[str, Any]:
|
||||
"""Convert a device registry entry to a dict for diagnostics.
|
||||
|
||||
This excludes internal fields that should not be exposed in diagnostics.
|
||||
"""
|
||||
return attr.asdict(entry, filter=_device_entry_filter)
|
||||
|
||||
|
||||
def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
|
||||
return a.name not in (
|
||||
"_cache",
|
||||
|
||||
@@ -10,8 +10,15 @@ from duco_connectivity.exceptions import (
|
||||
DucoConnectionError,
|
||||
DucoError,
|
||||
DucoResponseError,
|
||||
DucoUnsupportedCapabilityError,
|
||||
)
|
||||
from duco_connectivity.models import (
|
||||
BoardInfo,
|
||||
Node,
|
||||
NodeListActionItemList,
|
||||
NodeName,
|
||||
VentilationTemperatureInfo,
|
||||
)
|
||||
from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList, NodeName
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -26,7 +33,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
type DucoConfigEntry = ConfigEntry[DucoCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class DucoData:
|
||||
"""Data returned by the Duco coordinator."""
|
||||
|
||||
@@ -34,6 +41,7 @@ class DucoData:
|
||||
node_actions: NodeListActionItemList
|
||||
rssi_wifi: int | None
|
||||
time_filter_remain: int | None
|
||||
ventilation_temperatures: VentilationTemperatureInfo | None
|
||||
|
||||
|
||||
class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
@@ -42,6 +50,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
config_entry: DucoConfigEntry
|
||||
board_info: BoardInfo
|
||||
_supports_time_filter_remain: bool
|
||||
_supports_ventilation_temperatures: bool
|
||||
_configured_node_names: dict[int, str]
|
||||
|
||||
def __init__(
|
||||
@@ -61,6 +70,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
self.client = client
|
||||
self._configured_node_names = {}
|
||||
self._supports_time_filter_remain = True
|
||||
self._supports_ventilation_temperatures = True
|
||||
|
||||
async def _async_load_node_names(self) -> None:
|
||||
"""Load configured Duco node names during setup."""
|
||||
@@ -175,9 +185,26 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
time_filter_remain = await self.client.async_get_time_filter_remaining()
|
||||
self._supports_time_filter_remain = time_filter_remain is not None
|
||||
|
||||
ventilation_temperatures = (
|
||||
self.data.ventilation_temperatures if self.data else None
|
||||
)
|
||||
if self._supports_ventilation_temperatures:
|
||||
try:
|
||||
ventilation_temperatures = (
|
||||
await self.client.async_get_ventilation_temperature_info()
|
||||
)
|
||||
except DucoUnsupportedCapabilityError:
|
||||
ventilation_temperatures = None
|
||||
self._supports_ventilation_temperatures = False
|
||||
except DucoError as err:
|
||||
_LOGGER.debug(
|
||||
"Could not fetch Duco ventilation temperatures", exc_info=err
|
||||
)
|
||||
|
||||
return DucoData(
|
||||
nodes={node.node_id: node for node in nodes},
|
||||
node_actions=node_actions,
|
||||
rssi_wifi=rssi_wifi,
|
||||
time_filter_remain=time_filter_remain,
|
||||
ventilation_temperatures=ventilation_temperatures,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco_connectivity"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-connectivity==0.8.0"],
|
||||
"requirements": ["python-duco-connectivity==0.10.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.const import (
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfRatio,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -156,6 +157,70 @@ BOX_SENSOR_DESCRIPTIONS: tuple[DucoBoxSensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda coordinator: coordinator.data.rssi_wifi,
|
||||
),
|
||||
DucoBoxSensorEntityDescription(
|
||||
key="outdoor_air_temperature",
|
||||
translation_key="outdoor_air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
supported_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures is not None
|
||||
and coordinator.data.ventilation_temperatures.temp_oda is not None
|
||||
),
|
||||
value_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures.temp_oda
|
||||
if coordinator.data.ventilation_temperatures
|
||||
else None
|
||||
),
|
||||
),
|
||||
DucoBoxSensorEntityDescription(
|
||||
key="supply_air_temperature",
|
||||
translation_key="supply_air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
supported_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures is not None
|
||||
and coordinator.data.ventilation_temperatures.temp_sup is not None
|
||||
),
|
||||
value_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures.temp_sup
|
||||
if coordinator.data.ventilation_temperatures
|
||||
else None
|
||||
),
|
||||
),
|
||||
DucoBoxSensorEntityDescription(
|
||||
key="extract_air_temperature",
|
||||
translation_key="extract_air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
supported_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures is not None
|
||||
and coordinator.data.ventilation_temperatures.temp_eta is not None
|
||||
),
|
||||
value_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures.temp_eta
|
||||
if coordinator.data.ventilation_temperatures
|
||||
else None
|
||||
),
|
||||
),
|
||||
DucoBoxSensorEntityDescription(
|
||||
key="exhaust_air_temperature",
|
||||
translation_key="exhaust_air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
supported_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures is not None
|
||||
and coordinator.data.ventilation_temperatures.temp_eha is not None
|
||||
),
|
||||
value_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures.temp_eha
|
||||
if coordinator.data.ventilation_temperatures
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"exhaust_air_temperature": {
|
||||
"name": "Exhaust air temperature"
|
||||
},
|
||||
"extract_air_temperature": {
|
||||
"name": "Extract air temperature"
|
||||
},
|
||||
"filter_remaining": {
|
||||
"name": "Filter remaining"
|
||||
},
|
||||
@@ -82,6 +88,12 @@
|
||||
"iaq_rh": {
|
||||
"name": "Humidity air quality index"
|
||||
},
|
||||
"outdoor_air_temperature": {
|
||||
"name": "Outdoor air temperature"
|
||||
},
|
||||
"supply_air_temperature": {
|
||||
"name": "Supply air temperature"
|
||||
},
|
||||
"target_flow_level": {
|
||||
"name": "Target flow level"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ async def async_setup_entry(
|
||||
"""Set up a config entry."""
|
||||
device_registry = dr.async_get(hass)
|
||||
if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}):
|
||||
device_registry.async_clear_config_entry(entry.entry_id)
|
||||
device_registry.async_clear_config_entry(entry.entry_id, entry.domain)
|
||||
coordinator = DwdWeatherWarningsCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
|
||||
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
|
||||
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
|
||||
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
|
||||
from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
|
||||
@@ -141,6 +142,48 @@ COMMAND_SET_BUTTONS: dict[
|
||||
command_code=EdifierRC20GCode.COAX,
|
||||
),
|
||||
),
|
||||
EdifierCommandSet.S3000PRO: (
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="usb",
|
||||
translation_key="usb",
|
||||
command_code=EdifierS3000ProCode.USB,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="bluetooth",
|
||||
translation_key="bluetooth",
|
||||
command_code=EdifierS3000ProCode.BLUETOOTH,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="line_bal",
|
||||
translation_key="line_bal",
|
||||
command_code=EdifierS3000ProCode.LINE_BAL,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="opt_coax",
|
||||
translation_key="opt_coax",
|
||||
command_code=EdifierS3000ProCode.OPT_COAX,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="eq_monitor",
|
||||
translation_key="eq_monitor",
|
||||
command_code=EdifierS3000ProCode.EQ_MONITOR,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="eq_dynamic",
|
||||
translation_key="eq_dynamic",
|
||||
command_code=EdifierS3000ProCode.EQ_DYNAMIC,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="eq_classic",
|
||||
translation_key="eq_classic",
|
||||
command_code=EdifierS3000ProCode.EQ_CLASSIC,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="eq_vocal",
|
||||
translation_key="eq_vocal",
|
||||
command_code=EdifierS3000ProCode.EQ_VOCAL,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
|
||||
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
|
||||
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
|
||||
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
|
||||
from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode
|
||||
|
||||
DOMAIN = "edifier_infrared"
|
||||
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
|
||||
@@ -16,4 +17,5 @@ type EdifierCode = (
|
||||
| EdifierR1280TCode
|
||||
| EdifierS360DBCode
|
||||
| EdifierRC20GCode
|
||||
| EdifierS3000ProCode
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
|
||||
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
|
||||
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
|
||||
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
|
||||
from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode
|
||||
|
||||
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -92,6 +93,19 @@ COMMAND_SET_COMMANDS: dict[
|
||||
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,),
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,),
|
||||
},
|
||||
EdifierCommandSet.S3000PRO: {
|
||||
MediaPlayerEntityFeature.TURN_ON: (EdifierS3000ProCode.POWER,),
|
||||
MediaPlayerEntityFeature.TURN_OFF: (EdifierS3000ProCode.POWER,),
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierS3000ProCode.VOLUME_UP,),
|
||||
(EdifierS3000ProCode.VOLUME_DOWN,),
|
||||
),
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierS3000ProCode.MUTE,),
|
||||
MediaPlayerEntityFeature.PLAY: (EdifierS3000ProCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.PAUSE: (EdifierS3000ProCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS3000ProCode.NEXT,),
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS3000ProCode.PREVIOUS,),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@
|
||||
"coax": {
|
||||
"name": "Coaxial"
|
||||
},
|
||||
"eq_classic": {
|
||||
"name": "Classic EQ"
|
||||
},
|
||||
"eq_dynamic": {
|
||||
"name": "Dynamic EQ"
|
||||
},
|
||||
"eq_monitor": {
|
||||
"name": "Monitor EQ"
|
||||
},
|
||||
"eq_vocal": {
|
||||
"name": "Vocal EQ"
|
||||
},
|
||||
"fx_off": {
|
||||
"name": "FX off"
|
||||
},
|
||||
@@ -42,11 +54,20 @@
|
||||
"line_2": {
|
||||
"name": "Line 2"
|
||||
},
|
||||
"line_bal": {
|
||||
"name": "Line / Balanced"
|
||||
},
|
||||
"opt_coax": {
|
||||
"name": "Optical / Coaxial"
|
||||
},
|
||||
"optical": {
|
||||
"name": "Optical"
|
||||
},
|
||||
"pc": {
|
||||
"name": "PC"
|
||||
},
|
||||
"usb": {
|
||||
"name": "USB"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
DEVICE_DEFAULT_NAME,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
@@ -179,7 +178,7 @@ class EmbyDevice(MediaPlayerEntity):
|
||||
@override
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return f"Emby {self.device.name}" or DEVICE_DEFAULT_NAME
|
||||
return f"Emby {self.device.name}"
|
||||
|
||||
@property
|
||||
@override
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sense_energy"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["sense-energy==0.14.1"]
|
||||
"requirements": ["sense-energy==0.14.3"]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ from energieleser import EnergieleserClient
|
||||
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnergieleserConfigEntry, EnergieleserCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
@@ -30,4 +32,5 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: EnergieleserConfigEntry
|
||||
) -> bool:
|
||||
"""Unload an energieleser config entry."""
|
||||
ir.async_delete_issue(hass, DOMAIN, f"pin_locked_{entry.entry_id}")
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -9,11 +9,13 @@ from energieleser import (
|
||||
EnergieleserDevice,
|
||||
EnergieleserError,
|
||||
EnergieleserUnknownDeviceError,
|
||||
StromleserOneDevice,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
@@ -74,4 +76,23 @@ class EnergieleserCoordinator(DataUpdateCoordinator[EnergieleserDevice]):
|
||||
"device_id": self.device_id,
|
||||
},
|
||||
) from err
|
||||
if isinstance(device, StromleserOneDevice):
|
||||
issue_id = f"pin_locked_{self.config_entry.entry_id}"
|
||||
if device.pin_locked:
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
learn_more_url="https://docs.energieleser.de/en/docs/stromleser-one/installation/preparation",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="meter_locked",
|
||||
translation_placeholders={
|
||||
"device_name": self.config_entry.title,
|
||||
},
|
||||
)
|
||||
else:
|
||||
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
|
||||
|
||||
return device
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"""Diagnostics support for energieleser."""
|
||||
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import EnergieleserConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_DEVICE_ID, "fabrication_number"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: EnergieleserConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
device_data = coordinator.data
|
||||
|
||||
device_data_dict = dataclasses.asdict(device_data)
|
||||
|
||||
return async_redact_data(device_data_dict, TO_REDACT)
|
||||
@@ -49,7 +49,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
@@ -69,7 +69,7 @@ rules:
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
repair-issues: done
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: One device per config entry; the device is removed when the entry is removed.
|
||||
|
||||
@@ -103,5 +103,11 @@
|
||||
"unknown_device": {
|
||||
"message": "The device type for {device_id} is unknown or unsupported"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"meter_locked": {
|
||||
"description": "The electricity meter connected to {device_name} is not providing high-resolution data. You need to unlock the physical meter by entering the PIN (provided by your electricity company or grid operator) directly on the meter. Once the meter is unlocked, high-resolution data will be provided and this issue will resolve itself automatically. See the linked instructions for details on how to enter the PIN.",
|
||||
"title": "Meter PIN Required"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import ClientResponse
|
||||
from attr import asdict
|
||||
from pyenphase.envoy import Envoy
|
||||
from pyenphase.exceptions import EnvoyError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
|
||||
from homeassistant.components.diagnostics import (
|
||||
async_redact_data,
|
||||
device_entry_as_dict,
|
||||
entity_entry_as_dict,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
@@ -119,10 +122,7 @@ async def async_get_config_entry_diagnostics(
|
||||
state_dict.pop("context", None)
|
||||
entity_dict = entity_entry_as_dict(entity)
|
||||
entities.append({"entity": entity_dict, "state": state_dict})
|
||||
device_dict = asdict(device)
|
||||
device_dict.pop("_cache", None)
|
||||
# This can be removed when suggested_area is removed from DeviceEntry
|
||||
device_dict.pop("_suggested_area")
|
||||
device_dict = device_entry_as_dict(device)
|
||||
device_entities.append({"device": device_dict, "entities": entities})
|
||||
|
||||
# remove envoy serial
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import logging
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError
|
||||
from aioesphomeapi import APIConnectionError
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.components.bluetooth import async_remove_scanner
|
||||
@@ -11,13 +11,7 @@ from homeassistant.components.usb import (
|
||||
USBDevice,
|
||||
async_register_serial_port_scanner,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
__version__ as ha_version,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import async_delete_issue
|
||||
@@ -29,15 +23,18 @@ from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
|
||||
from .domain_data import DomainData
|
||||
from .encryption_key_storage import async_get_encryption_key_storage
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance
|
||||
from .manager import (
|
||||
DEVICE_CONFLICT_ISSUE_FORMAT,
|
||||
ESPHomeManager,
|
||||
async_create_api_client,
|
||||
cleanup_instance,
|
||||
)
|
||||
from .websocket_api import async_setup as async_setup_websocket_api
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
CLIENT_INFO = f"Home Assistant {ha_version}"
|
||||
|
||||
|
||||
@callback
|
||||
def _async_scan_serial_ports(
|
||||
@@ -90,20 +87,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool:
|
||||
"""Set up the esphome component."""
|
||||
host: str = entry.data[CONF_HOST]
|
||||
port: int = entry.data[CONF_PORT]
|
||||
password: str | None = entry.data[CONF_PASSWORD]
|
||||
noise_psk: str | None = entry.data.get(CONF_NOISE_PSK)
|
||||
|
||||
zeroconf_instance = await zeroconf.async_get_instance(hass)
|
||||
|
||||
cli = APIClient(
|
||||
host,
|
||||
port,
|
||||
password,
|
||||
client_info=CLIENT_INFO,
|
||||
zeroconf_instance=zeroconf_instance,
|
||||
noise_psk=noise_psk,
|
||||
timezone=hass.config.time_zone,
|
||||
cli = async_create_api_client(
|
||||
hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK)
|
||||
)
|
||||
|
||||
domain_data = DomainData.get(hass)
|
||||
@@ -159,21 +148,10 @@ async def _async_clear_dynamic_encryption_key(
|
||||
if await storage.async_get_key(entry.unique_id) is None:
|
||||
return
|
||||
|
||||
host: str = entry.data[CONF_HOST]
|
||||
port: int = entry.data[CONF_PORT]
|
||||
password: str | None = entry.data[CONF_PASSWORD]
|
||||
noise_psk: str | None = entry.data.get(CONF_NOISE_PSK)
|
||||
|
||||
zeroconf_instance = await zeroconf.async_get_instance(hass)
|
||||
|
||||
cli = APIClient(
|
||||
host,
|
||||
port,
|
||||
password,
|
||||
client_info=CLIENT_INFO,
|
||||
zeroconf_instance=zeroconf_instance,
|
||||
noise_psk=noise_psk,
|
||||
timezone=hass.config.time_zone,
|
||||
cli = async_create_api_client(
|
||||
hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK)
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -74,7 +74,11 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
|
||||
ERROR_INVALID_PASSWORD_AUTH = "invalid_auth"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
|
||||
# A deliberately wrong key (base64 of thirty two ASCII zero characters, not
|
||||
# zero bytes) used only to elicit the server hello so the device name can be
|
||||
# read. Not to be confused with aioesphomeapi.ZERO_NOISE_PSK, the well known
|
||||
# all zeros provisioning key.
|
||||
PROBE_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
|
||||
DEFAULT_NAME = "ESPHome"
|
||||
|
||||
_BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector(
|
||||
@@ -271,7 +275,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
# to get the device name which will allow us to populate
|
||||
# the device name and hopefully get the encryption key
|
||||
# from the dashboard.
|
||||
self._noise_psk = ZERO_NOISE_PSK
|
||||
self._noise_psk = PROBE_NOISE_PSK
|
||||
response = await self.fetch_device_info()
|
||||
self._noise_psk = None
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Final
|
||||
from awesomeversion import AwesomeVersion
|
||||
|
||||
from homeassistant.components.bluetooth import BluetoothScanningMode
|
||||
from homeassistant.const import __version__ as ha_version
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -12,6 +13,8 @@ if TYPE_CHECKING:
|
||||
|
||||
DOMAIN = "esphome"
|
||||
|
||||
CLIENT_INFO = f"Home Assistant {ha_version}"
|
||||
|
||||
ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN)
|
||||
|
||||
CONF_ALLOW_SERVICE_CALLS = "allow_service_calls"
|
||||
|
||||
@@ -9,6 +9,7 @@ import struct
|
||||
from typing import TYPE_CHECKING, Any, Final, NamedTuple
|
||||
|
||||
from aioesphomeapi import (
|
||||
ZERO_NOISE_PSK,
|
||||
APIClient,
|
||||
APIConnectionError,
|
||||
APIVersion,
|
||||
@@ -34,7 +35,10 @@ import voluptuous as vol
|
||||
from homeassistant.components import bluetooth, tag, zeroconf
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
CONF_HOST,
|
||||
CONF_MODE,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
EVENT_HOMEASSISTANT_CLOSE,
|
||||
EVENT_LOGGING_CHANGED,
|
||||
Platform,
|
||||
@@ -77,6 +81,7 @@ from homeassistant.util.json import json_loads_object
|
||||
|
||||
from .bluetooth import async_connect_scanner
|
||||
from .const import (
|
||||
CLIENT_INFO,
|
||||
CONF_ALLOW_SERVICE_CALLS,
|
||||
CONF_BLUETOOTH_MAC_ADDRESS,
|
||||
CONF_DEVICE_NAME,
|
||||
@@ -101,6 +106,26 @@ DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
|
||||
UNPACK_UINT32_BE = struct.Struct(">I").unpack_from
|
||||
|
||||
|
||||
@callback
|
||||
def async_create_api_client(
|
||||
hass: HomeAssistant,
|
||||
entry: ESPHomeConfigEntry,
|
||||
zeroconf_instance: zeroconf.HaZeroconf,
|
||||
*,
|
||||
noise_psk: str | None,
|
||||
) -> APIClient:
|
||||
"""Create an APIClient for a config entry."""
|
||||
return APIClient(
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_PORT],
|
||||
entry.data[CONF_PASSWORD],
|
||||
client_info=CLIENT_INFO,
|
||||
zeroconf_instance=zeroconf_instance,
|
||||
noise_psk=noise_psk,
|
||||
timezone=hass.config.time_zone,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001
|
||||
|
||||
@@ -812,6 +837,51 @@ class ESPHomeManager:
|
||||
if self.reconnect_logic:
|
||||
await self.reconnect_logic.stop()
|
||||
|
||||
async def _async_provision_key_over_noise(self, new_key: bytes) -> bool:
|
||||
"""Send the encryption key over a short lived zero PSK Noise connection.
|
||||
|
||||
The well known all zeros PSK still runs a fresh ephemeral X25519
|
||||
exchange, so the key cannot be read by a passive listener on the
|
||||
network. This protects against sniffing only; it does not
|
||||
authenticate either side against an active man in the middle.
|
||||
|
||||
Returns True if the device accepted the key. On failure the caller
|
||||
simply returns; provisioning runs again on the next connect cycle.
|
||||
"""
|
||||
unique_id = self.entry.unique_id
|
||||
cli = async_create_api_client(
|
||||
self.hass, self.entry, self.zeroconf_instance, noise_psk=ZERO_NOISE_PSK
|
||||
)
|
||||
device_name = self.entry.data.get(CONF_DEVICE_NAME, self.host)
|
||||
try:
|
||||
await cli.connect()
|
||||
if await cli.noise_encryption_set_key(new_key):
|
||||
return True
|
||||
_LOGGER.error(
|
||||
"Device %s (%s) rejected the encryption key",
|
||||
device_name,
|
||||
unique_id,
|
||||
)
|
||||
except InvalidEncryptionKeyAPIError:
|
||||
_LOGGER.error(
|
||||
"Device %s (%s) rejected the zero PSK handshake; it appears "
|
||||
"to already have an encryption key set",
|
||||
device_name,
|
||||
unique_id,
|
||||
)
|
||||
except APIConnectionError as ex:
|
||||
# Whatever went wrong, we never downgrade to a plaintext push;
|
||||
# provisioning simply runs again on the next connect cycle
|
||||
_LOGGER.error(
|
||||
"Error provisioning encryption key for device %s (%s): %s",
|
||||
device_name,
|
||||
unique_id,
|
||||
ex,
|
||||
)
|
||||
finally:
|
||||
await cli.disconnect(force=True)
|
||||
return False
|
||||
|
||||
async def _handle_dynamic_encryption_key(
|
||||
self, device_info: EsphomeDeviceInfo
|
||||
) -> None:
|
||||
@@ -853,18 +923,24 @@ class ESPHomeManager:
|
||||
new_key = base64.b64encode(secrets.token_bytes(32))
|
||||
new_key_str = new_key.decode()
|
||||
|
||||
try:
|
||||
# Store the key on the device using the existing connection
|
||||
result = await self.cli.noise_encryption_set_key(new_key)
|
||||
except APIConnectionError as ex:
|
||||
_LOGGER.error(
|
||||
"Connection error while storing encryption key for device %s (%s): %s",
|
||||
self.entry.data.get(CONF_DEVICE_NAME, self.host),
|
||||
self.entry.unique_id,
|
||||
ex,
|
||||
)
|
||||
return
|
||||
if device_info.api_encryption_provisionable:
|
||||
# New firmware: send the key over an encrypted zero PSK Noise
|
||||
# connection so it cannot be sniffed off the network
|
||||
if not await self._async_provision_key_over_noise(new_key):
|
||||
return
|
||||
else:
|
||||
# Old firmware only accepts the key over the existing plaintext
|
||||
# connection. Deprecated; will be removed after the usual window.
|
||||
try:
|
||||
result = await self.cli.noise_encryption_set_key(new_key)
|
||||
except APIConnectionError as ex:
|
||||
_LOGGER.error(
|
||||
"Connection error while storing encryption key for device %s (%s): %s",
|
||||
self.entry.data.get(CONF_DEVICE_NAME, self.host),
|
||||
self.entry.unique_id,
|
||||
ex,
|
||||
)
|
||||
return
|
||||
if not result:
|
||||
_LOGGER.error(
|
||||
"Failed to set dynamic encryption key on device %s (%s)",
|
||||
@@ -977,6 +1053,10 @@ class ESPHomeManager:
|
||||
self._async_cleanup()
|
||||
if device_info.name:
|
||||
reconnect_logic.name = device_info.name
|
||||
# Seed the backoff cap from the restored device_info so the first
|
||||
# reconnect after a restart already caps for a deep-sleep device,
|
||||
# before the first live connect refreshes it.
|
||||
reconnect_logic.deep_sleep = device_info.has_deep_sleep
|
||||
if (
|
||||
bluetooth_mac_address := device_info.bluetooth_mac_address
|
||||
) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.6.0",
|
||||
"aioesphomeapi==45.6.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.9.7"
|
||||
],
|
||||
|
||||
@@ -15,7 +15,6 @@ import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -174,13 +173,13 @@ class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
class CannotConnect(Exception):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
class InvalidAuth(Exception):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
class FireflyClientTimeout(HomeAssistantError):
|
||||
class FireflyClientTimeout(Exception):
|
||||
"""Error to indicate a timeout occurred."""
|
||||
|
||||
@@ -87,7 +87,7 @@ def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None:
|
||||
domain=DOMAIN,
|
||||
issue_id="deprecated_cleanup_button",
|
||||
is_fixable=False,
|
||||
is_persistent=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_cleanup_button",
|
||||
translation_placeholders={"removal_version": "2026.11.0"},
|
||||
@@ -114,7 +114,7 @@ def repair_issue_firmware_update(hass: HomeAssistant, avm_wrapper: AvmWrapper) -
|
||||
domain=DOMAIN,
|
||||
issue_id="deprecated_firmware_update_button",
|
||||
is_fixable=False,
|
||||
is_persistent=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_firmware_update_button",
|
||||
translation_placeholders={"removal_version": "2026.11.0"},
|
||||
|
||||
@@ -743,9 +743,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
):
|
||||
if not any(con in device.connections for con in valid_connections):
|
||||
_LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||
device_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
device_reg.async_remove_device(device.id)
|
||||
|
||||
fritz_data = self.hass.data[FRITZ_DATA_KEY]
|
||||
|
||||
|
||||
@@ -121,9 +121,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
):
|
||||
if not set(device.identifiers) & identifiers:
|
||||
LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||
device_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
device_reg.async_remove_device(device.id)
|
||||
|
||||
def _update_fritz_devices(self) -> FritzboxCoordinatorData:
|
||||
"""Update all fritzbox device data."""
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260624.5"]
|
||||
"requirements": ["home-assistant-frontend==20260624.6"]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from gardena_bluetooth.const import (
|
||||
Battery,
|
||||
EventHistory,
|
||||
FlowStatistics,
|
||||
Pump,
|
||||
Sensor,
|
||||
Spray,
|
||||
Valve,
|
||||
@@ -28,6 +29,8 @@ from homeassistant.const import (
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfPressure,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
@@ -164,6 +167,24 @@ DESCRIPTIONS = (
|
||||
char=FlowStatistics.last_reset,
|
||||
get=_get_timestamp,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Pump.tank_preassure.unique_id,
|
||||
translation_key="tank_pressure",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.MBAR,
|
||||
suggested_unit_of_measurement=UnitOfPressure.BAR,
|
||||
suggested_display_precision=2,
|
||||
char=Pump.tank_preassure,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Pump.water_temperature.unique_id,
|
||||
translation_key="water_temperature",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
char=Pump.water_temperature,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Spray.current_distance.unique_id,
|
||||
translation_key="spray_current_distance",
|
||||
|
||||
@@ -151,6 +151,12 @@
|
||||
},
|
||||
"spray_current_sector": {
|
||||
"name": "Current sector"
|
||||
},
|
||||
"tank_pressure": {
|
||||
"name": "Tank pressure"
|
||||
},
|
||||
"water_temperature": {
|
||||
"name": "Water temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""The Gatus integration."""
|
||||
|
||||
from homeassistant.const import CONF_URL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool:
|
||||
"""Set up Gatus from a config entry."""
|
||||
coordinator = GatusDataUpdateCoordinator(hass, entry, entry.data[CONF_URL])
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Support for Gatus binary sensors."""
|
||||
|
||||
from typing import override
|
||||
|
||||
from gatus_api import EndpointStatus, Result
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GatusConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Gatus binary sensor platform."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
GatusEndpointBinarySensor(coordinator, entry, endpoint_key)
|
||||
for endpoint_key in coordinator.data
|
||||
)
|
||||
|
||||
|
||||
class GatusEndpointBinarySensor(
|
||||
CoordinatorEntity[GatusDataUpdateCoordinator], BinarySensorEntity
|
||||
):
|
||||
"""Representation of a Gatus endpoint status."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GatusDataUpdateCoordinator,
|
||||
entry: GatusConfigEntry,
|
||||
endpoint_key: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._endpoint_key = endpoint_key
|
||||
|
||||
endpoint_data = self.endpoint_data
|
||||
|
||||
endpoint_name = endpoint_data.name
|
||||
if endpoint_data.group is not None:
|
||||
device_name = f"{endpoint_data.group} {endpoint_name}"
|
||||
else:
|
||||
device_name = endpoint_name
|
||||
|
||||
self._attr_unique_id = f"{entry.entry_id}_{endpoint_key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{entry.entry_id}_{endpoint_key}")},
|
||||
name=device_name,
|
||||
manufacturer="Gatus",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the endpoint is up and healthy."""
|
||||
latest_result = self.latest_result
|
||||
if latest_result is None:
|
||||
return None
|
||||
|
||||
return latest_result.success
|
||||
|
||||
@property
|
||||
@override
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
data = self.coordinator.data
|
||||
# Guard for empty results list, which could imply a brand new endpoint
|
||||
return (
|
||||
super().available
|
||||
and self._endpoint_key in data
|
||||
and bool(data[self._endpoint_key].results)
|
||||
)
|
||||
|
||||
@property
|
||||
def endpoint_data(self) -> EndpointStatus:
|
||||
"""Return this specific endpoint's data from the coordinator."""
|
||||
return self.coordinator.data[self._endpoint_key]
|
||||
|
||||
@property
|
||||
def latest_result(self) -> Result | None:
|
||||
"""Return the most recent monitoring result (Gatus appends newest last)."""
|
||||
results = self.endpoint_data.results
|
||||
if not results:
|
||||
return None
|
||||
return results[-1]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Config flow for the Gatus integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
from gatus_api import GatusClient, GatusClientError
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
"""Validate that the user input allows us to connect to Gatus and return data."""
|
||||
client = GatusClient(url=data[CONF_URL], session=async_get_clientsession(hass))
|
||||
|
||||
try:
|
||||
await client.get_endpoints_statuses()
|
||||
except GatusClientError as err:
|
||||
_LOGGER.debug("Cannot connect to Gatus instance at %s: %s", data[CONF_URL], err)
|
||||
raise CannotConnect from err
|
||||
|
||||
|
||||
class GatusConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Gatus."""
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial setup step when adding the integration via the UI."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
user_input[CONF_URL] = str(
|
||||
URL(user_input[CONF_URL])
|
||||
.with_query(None)
|
||||
.with_fragment(None)
|
||||
.with_user(None)
|
||||
.with_password(None)
|
||||
).rstrip("/")
|
||||
|
||||
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
|
||||
|
||||
try:
|
||||
await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during Gatus setup")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(title="Gatus", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of an existing entry."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
url = URL(user_input[CONF_URL])
|
||||
user_input[CONF_URL] = str(
|
||||
url.with_query(None)
|
||||
.with_fragment(None)
|
||||
.with_user(None)
|
||||
.with_password(None)
|
||||
).rstrip("/")
|
||||
|
||||
if user_input[CONF_URL] != reconfigure_entry.data[CONF_URL]:
|
||||
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
|
||||
|
||||
try:
|
||||
await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during Gatus reconfigure")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input or reconfigure_entry.data
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect to the server."""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Gatus integration."""
|
||||
|
||||
DOMAIN = "gatus"
|
||||
@@ -0,0 +1,48 @@
|
||||
"""DataUpdateCoordinator for the Gatus integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from gatus_api import EndpointStatus, GatusClient, GatusClientError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type GatusConfigEntry = ConfigEntry[GatusDataUpdateCoordinator]
|
||||
|
||||
|
||||
class GatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, EndpointStatus]]):
|
||||
"""Class to manage fetching Gatus data from the API via third-party library."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: GatusConfigEntry, url: str) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.url = url.rstrip("/")
|
||||
self.client = GatusClient(url=self.url, session=async_get_clientsession(hass))
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> dict[str, EndpointStatus]:
|
||||
"""Fetch endpoint statuses from the Gatus API."""
|
||||
try:
|
||||
raw_endpoints = await self.client.get_endpoints_statuses()
|
||||
except GatusClientError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
) from err
|
||||
|
||||
return {ep.key: ep for ep in raw_endpoints}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Diagnostics support for Gatus."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import GatusConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: GatusConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"key": ep.key,
|
||||
"name": ep.name,
|
||||
"group": ep.group,
|
||||
"results": [
|
||||
{
|
||||
"success": r.success,
|
||||
"status": r.status,
|
||||
}
|
||||
for r in ep.results
|
||||
],
|
||||
}
|
||||
for ep in coordinator.data.values()
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "gatus",
|
||||
"name": "Gatus",
|
||||
"codeowners": ["@TN-1"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/gatus",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["gatus_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["gatus-api==1.0.3"]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-conditions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom conditions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
docs-triggers:
|
||||
status: exempt
|
||||
comment: Integration does not register custom triggers.
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Integration does not use authentication.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Integration does not support discovery.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: All entities represent monitored services and should be enabled by default.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: Entity names are dynamically provided by the Gatus service.
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: Entities use the connectivity device class for their icon and define no custom icons.
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration does not require user intervention repairs.
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "[%key:component::gatus::config::step::user::data_description::url%]"
|
||||
},
|
||||
"description": "[%key:component::gatus::config::step::user::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "The full base URL of your Gatus status page instance including protocol and port."
|
||||
},
|
||||
"description": "Enter the network details for your Gatus status page instance. Make sure to include the protocol (e.g., `http://` or `https://`) and the port number if you are not using a standard port."
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_failed": {
|
||||
"message": "Error communicating with Gatus API"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# humidifier's device.
|
||||
async_handle_source_entity_changes(
|
||||
hass,
|
||||
add_helper_config_entry_to_device=False,
|
||||
helper_config_entry_id=entry.entry_id,
|
||||
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
|
||||
source_device_id=async_entity_id_to_device_id(
|
||||
|
||||
@@ -33,7 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# heater's device.
|
||||
async_handle_source_entity_changes(
|
||||
hass,
|
||||
add_helper_config_entry_to_device=False,
|
||||
helper_config_entry_id=entry.entry_id,
|
||||
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
|
||||
source_device_id=async_entity_id_to_device_id(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import override
|
||||
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
|
||||
from homeassistant.const import EntityStateAttribute
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -92,8 +92,8 @@ class GeofencyEntity(TrackerEntity, RestoreEntity):
|
||||
return
|
||||
|
||||
attr = state.attributes
|
||||
self._attr_latitude = attr.get(ATTR_LATITUDE)
|
||||
self._attr_longitude = attr.get(ATTR_LONGITUDE)
|
||||
self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE)
|
||||
self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE)
|
||||
|
||||
@override
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
|
||||
@@ -98,9 +98,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: GithubConfigEntry) ->
|
||||
if device := dev_reg.async_get_device({(DOMAIN, repository)}):
|
||||
dev_reg.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
add_config_subentry_id=subentry.subentry_id,
|
||||
add_config_entry_id=entry.entry_id,
|
||||
new_config_entry_id=entry.entry_id,
|
||||
new_config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
return True
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||
|
||||
from .const import (
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
@@ -182,7 +182,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
# Device and entity registries will set the disabled_by flag to None
|
||||
# when moving a device or entity disabled by CONFIG_ENTRY to an enabled
|
||||
# config entry, but we want to set it to USER instead,
|
||||
device_disabled_by = device.disabled_by
|
||||
device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED
|
||||
if (
|
||||
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
@@ -192,20 +192,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
device.id,
|
||||
disabled_by=device_disabled_by,
|
||||
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
add_config_subentry_id=subentry.subentry_id,
|
||||
add_config_entry_id=parent_entry.entry_id,
|
||||
new_config_entry_id=parent_entry.entry_id,
|
||||
new_config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
if parent_entry.entry_id != entry.entry_id:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
)
|
||||
else:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
if not use_existing:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Coordinators for Google Health."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
@@ -12,9 +13,13 @@ from google_health_api.exceptions import (
|
||||
HealthAuthException,
|
||||
)
|
||||
from google_health_api.model import (
|
||||
ActiveEnergyBurnedRollupValue,
|
||||
BodyFat,
|
||||
DailyRestingHeartRate,
|
||||
DistanceRollupValue,
|
||||
FloorsRollupValue,
|
||||
StepsRollupValue,
|
||||
TotalCaloriesRollupValue,
|
||||
Weight,
|
||||
)
|
||||
|
||||
@@ -40,6 +45,9 @@ class GoogleHealthActivityData:
|
||||
|
||||
steps: StepsRollupValue | None = None
|
||||
distance: DistanceRollupValue | None = None
|
||||
active_energy_burned: ActiveEnergyBurnedRollupValue | None = None
|
||||
total_calories: TotalCaloriesRollupValue | None = None
|
||||
floors: FloorsRollupValue | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -48,6 +56,7 @@ class GoogleHealthBodyData:
|
||||
|
||||
weight: Weight | None = None
|
||||
resting_heart_rate: DailyRestingHeartRate | None = None
|
||||
body_fat: BodyFat | None = None
|
||||
|
||||
|
||||
class GoogleHealthDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
@@ -116,20 +125,42 @@ class GoogleHealthActivityCoordinator(
|
||||
|
||||
@override
|
||||
async def _async_fetch_data(self) -> GoogleHealthActivityData:
|
||||
"""Fetch steps and distance rollup for today.
|
||||
"""Fetch activity rollups for today.
|
||||
|
||||
Queries the daily rollup endpoints using Home Assistant's local time zone
|
||||
to aggregate step and distance counts over the current civil day. If no
|
||||
data points exist for today yet, the API returns None, which the sensors
|
||||
default to 0.
|
||||
Queries the daily rollup endpoints in parallel using Home Assistant's
|
||||
local time zone to aggregate steps, distance, active calories, total
|
||||
calories, and floors. If no data points exist for today yet, the API
|
||||
returns None, which the sensors default to 0.
|
||||
"""
|
||||
steps_rollup = await self.api.steps.today(self.hass.config.time_zone)
|
||||
distance_rollup = await self.api.distance.today(self.hass.config.time_zone)
|
||||
(
|
||||
steps_rollup,
|
||||
distance_rollup,
|
||||
active_energy_rollup,
|
||||
total_calories_rollup,
|
||||
floors_rollup,
|
||||
) = await asyncio.gather(
|
||||
self.api.steps.today(self.hass.config.time_zone),
|
||||
self.api.distance.today(self.hass.config.time_zone),
|
||||
self.api.active_energy_burned.today(self.hass.config.time_zone),
|
||||
self.api.total_calories.today(self.hass.config.time_zone),
|
||||
self.api.floors.today(self.hass.config.time_zone),
|
||||
)
|
||||
|
||||
steps = steps_rollup.data if steps_rollup else None
|
||||
distance = distance_rollup.data if distance_rollup else None
|
||||
active_energy_burned = (
|
||||
active_energy_rollup.data if active_energy_rollup else None
|
||||
)
|
||||
total_calories = total_calories_rollup.data if total_calories_rollup else None
|
||||
floors = floors_rollup.data if floors_rollup else None
|
||||
|
||||
return GoogleHealthActivityData(steps=steps, distance=distance)
|
||||
return GoogleHealthActivityData(
|
||||
steps=steps,
|
||||
distance=distance,
|
||||
active_energy_burned=active_energy_burned,
|
||||
total_calories=total_calories,
|
||||
floors=floors,
|
||||
)
|
||||
|
||||
|
||||
class GoogleHealthBodyCoordinator(
|
||||
@@ -155,13 +186,14 @@ class GoogleHealthBodyCoordinator(
|
||||
|
||||
@override
|
||||
async def _async_fetch_data(self) -> GoogleHealthBodyData:
|
||||
"""Fetch latest body weight and resting heart rate."""
|
||||
"""Fetch latest body weight, resting heart rate, and body fat in parallel."""
|
||||
# The Google Health API returns data points sorted by interval start time
|
||||
# in descending order (newest first). Querying with page_size=1 and grabbing
|
||||
# the first element is sufficient to fetch the most recent measurement.
|
||||
weight_result = await self.api.weight.list(page_size=DEFAULT_PAGE_SIZE)
|
||||
hr_result = await self.api.daily_resting_heart_rate.list(
|
||||
page_size=DEFAULT_PAGE_SIZE
|
||||
weight_result, hr_result, body_fat_result = await asyncio.gather(
|
||||
self.api.weight.list(page_size=DEFAULT_PAGE_SIZE),
|
||||
self.api.daily_resting_heart_rate.list(page_size=DEFAULT_PAGE_SIZE),
|
||||
self.api.body_fat.list(page_size=DEFAULT_PAGE_SIZE),
|
||||
)
|
||||
|
||||
weight = (
|
||||
@@ -170,7 +202,12 @@ class GoogleHealthBodyCoordinator(
|
||||
resting_heart_rate = (
|
||||
hr_result.data_points[0].data if hr_result.data_points else None
|
||||
)
|
||||
body_fat = (
|
||||
body_fat_result.data_points[0].data if body_fat_result.data_points else None
|
||||
)
|
||||
|
||||
return GoogleHealthBodyData(
|
||||
weight=weight, resting_heart_rate=resting_heart_rate
|
||||
weight=weight,
|
||||
resting_heart_rate=resting_heart_rate,
|
||||
body_fat=body_fat,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfLength, UnitOfMass
|
||||
from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfMass
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -56,6 +56,32 @@ ACTIVITY_SENSORS: list[
|
||||
data.distance.millimeters_sum / 1000.0 if data and data.distance else 0.0
|
||||
),
|
||||
),
|
||||
GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float](
|
||||
key="active_calories",
|
||||
translation_key="active_calories",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda data: (
|
||||
data.active_energy_burned.kcal_sum
|
||||
if data and data.active_energy_burned
|
||||
else 0.0
|
||||
),
|
||||
),
|
||||
GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float](
|
||||
key="total_calories",
|
||||
translation_key="total_calories",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda data: (
|
||||
data.total_calories.kcal_sum if data and data.total_calories else 0.0
|
||||
),
|
||||
),
|
||||
GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, int](
|
||||
key="floors",
|
||||
translation_key="floors",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda data: data.floors.count_sum if data and data.floors else 0,
|
||||
),
|
||||
]
|
||||
|
||||
BODY_SENSORS: list[
|
||||
@@ -81,6 +107,15 @@ BODY_SENSORS: list[
|
||||
else None
|
||||
),
|
||||
),
|
||||
GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, float | None](
|
||||
key="body_fat",
|
||||
translation_key="body_fat",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: (
|
||||
data.body_fat.percentage if data and data.body_fat else None
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -35,12 +35,24 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"active_calories": {
|
||||
"name": "Active calories"
|
||||
},
|
||||
"body_fat": {
|
||||
"name": "Body fat"
|
||||
},
|
||||
"floors": {
|
||||
"name": "Floors"
|
||||
},
|
||||
"resting_heart_rate": {
|
||||
"name": "Resting heart rate"
|
||||
},
|
||||
"steps": {
|
||||
"name": "Steps",
|
||||
"unit_of_measurement": "steps"
|
||||
},
|
||||
"total_calories": {
|
||||
"name": "Total calories"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -57,7 +57,7 @@ from .schemas import (
|
||||
UNITS_SELECTOR,
|
||||
)
|
||||
|
||||
RECONFIGURE_SCHEMA = vol.Schema(
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): cv.string,
|
||||
vol.Required(CONF_DESTINATION): cv.string,
|
||||
@@ -65,14 +65,6 @@ RECONFIGURE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = RECONFIGURE_SCHEMA.extend(
|
||||
{
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=home-assistant-config-flow-name-field
|
||||
vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_LANGUAGE): LANGUAGE_SELECTOR,
|
||||
@@ -184,7 +176,7 @@ class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = await validate_input(self.hass, user_input)
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input.get(CONF_NAME, DEFAULT_NAME),
|
||||
title=DEFAULT_NAME,
|
||||
data=user_input,
|
||||
options=default_options(self.hass),
|
||||
)
|
||||
@@ -210,7 +202,7 @@ class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
RECONFIGURE_SCHEMA, self._get_reconfigure_entry().data
|
||||
CONFIG_SCHEMA, self._get_reconfigure_entry().data
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LANGUAGE,
|
||||
CONF_MODE,
|
||||
CONF_NAME,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
UnitOfTime,
|
||||
)
|
||||
@@ -74,7 +73,7 @@ async def async_setup_entry(
|
||||
api_key = config_entry.data[CONF_API_KEY]
|
||||
origin = config_entry.data[CONF_ORIGIN]
|
||||
destination = config_entry.data[CONF_DESTINATION]
|
||||
name = config_entry.data.get(CONF_NAME, DEFAULT_NAME)
|
||||
name = config_entry.title
|
||||
|
||||
client_options = ClientOptions(api_key=api_key)
|
||||
client = RoutesAsyncClient(client_options=client_options)
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"destination": "Destination",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"origin": "Origin"
|
||||
},
|
||||
"description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or a zone's friendly name (case-sensitive)"
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
from typing import override
|
||||
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.const import (
|
||||
ATTR_BATTERY_LEVEL,
|
||||
ATTR_GPS_ACCURACY,
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
from homeassistant.components.device_tracker import (
|
||||
TrackerEntity,
|
||||
TrackerEntityStateAttribute,
|
||||
)
|
||||
from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -120,9 +118,11 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity):
|
||||
return
|
||||
|
||||
attr = state.attributes
|
||||
self._attr_latitude = attr.get(ATTR_LATITUDE)
|
||||
self._attr_longitude = attr.get(ATTR_LONGITUDE)
|
||||
self._attr_location_accuracy = attr.get(ATTR_GPS_ACCURACY, 0)
|
||||
self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE)
|
||||
self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE)
|
||||
self._attr_location_accuracy = attr.get(
|
||||
TrackerEntityStateAttribute.GPS_ACCURACY, 0
|
||||
)
|
||||
self._attr_extra_state_attributes = {
|
||||
ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE),
|
||||
ATTR_ACTIVITY: attr.get(ATTR_ACTIVITY),
|
||||
|
||||
@@ -453,10 +453,7 @@ async def async_setup_entry(
|
||||
for device_sn in device_domain_ids:
|
||||
if coordinator := runtime_data.devices.pop(device_sn, None):
|
||||
await coordinator.async_shutdown()
|
||||
device_registry.async_update_device(
|
||||
device_entry.id,
|
||||
remove_config_entry_id=config_entry.entry_id,
|
||||
)
|
||||
device_registry.async_remove_device(device_entry.id)
|
||||
|
||||
# Add new devices
|
||||
new_coordinators: list[GrowattCoordinator] = []
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""The Harbor integration."""
|
||||
|
||||
from harbor.config import HarborCameraConfig
|
||||
|
||||
from homeassistant.const import CONF_IP_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import HarborConfigEntry, HarborCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool:
|
||||
"""Set up Harbor from a config entry."""
|
||||
coordinator = HarborCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
HarborCameraConfig(
|
||||
serial=entry.data[CONF_SERIAL],
|
||||
cert_pem=entry.data[CONF_CERT_PEM],
|
||||
key_pem=entry.data[CONF_KEY_PEM],
|
||||
ip_address=entry.data[CONF_IP_ADDRESS],
|
||||
),
|
||||
)
|
||||
await coordinator.async_start()
|
||||
try:
|
||||
await coordinator.async_wait_until_ready()
|
||||
except TimeoutError as err:
|
||||
await coordinator.async_shutdown()
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="cannot_connect"
|
||||
) from err
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool:
|
||||
"""Unload a Harbor config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await entry.runtime_data.async_shutdown()
|
||||
return unload_ok
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Config flow for Harbor."""
|
||||
|
||||
from typing import Any, override
|
||||
|
||||
from harbor.config import HarborCameraConfig
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_IP_ADDRESS
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN
|
||||
from .coordinator import async_probe_camera
|
||||
|
||||
SERIAL_LENGTH = 10
|
||||
|
||||
STEP_USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SERIAL): selector.TextSelector(selector.TextSelectorConfig()),
|
||||
vol.Required(CONF_CERT_PEM): selector.TextSelector(
|
||||
selector.TextSelectorConfig(multiline=True)
|
||||
),
|
||||
vol.Required(CONF_KEY_PEM): selector.TextSelector(
|
||||
selector.TextSelectorConfig(multiline=True)
|
||||
),
|
||||
vol.Required(CONF_IP_ADDRESS): selector.TextSelector(
|
||||
selector.TextSelectorConfig()
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_serial(value: str) -> bool:
|
||||
"""Validate the Harbor serial number."""
|
||||
return len(value) == SERIAL_LENGTH and value.isdigit()
|
||||
|
||||
|
||||
def _validate_cert_pem(value: str) -> bool:
|
||||
"""Validate a Harbor client certificate PEM blob."""
|
||||
value = value.strip()
|
||||
return value.startswith("-----BEGIN CERTIFICATE-----") and value.endswith(
|
||||
"-----END CERTIFICATE-----"
|
||||
)
|
||||
|
||||
|
||||
def _validate_key_pem(value: str) -> bool:
|
||||
"""Validate a Harbor private key PEM blob."""
|
||||
value = value.strip()
|
||||
return value.startswith("-----BEGIN PRIVATE KEY-----") and value.endswith(
|
||||
"-----END PRIVATE KEY-----"
|
||||
)
|
||||
|
||||
|
||||
def _validate_credentials(cert_pem: str, key_pem: str) -> dict[str, str]:
|
||||
"""Validate cert/key PEM blobs and return any errors."""
|
||||
errors: dict[str, str] = {}
|
||||
if not _validate_cert_pem(cert_pem):
|
||||
errors[CONF_CERT_PEM] = "invalid_cert"
|
||||
if not _validate_key_pem(key_pem):
|
||||
errors[CONF_KEY_PEM] = "invalid_key"
|
||||
return errors
|
||||
|
||||
|
||||
class HarborConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Harbor."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_SCHEMA,
|
||||
errors={},
|
||||
)
|
||||
|
||||
normalized = {
|
||||
key: value.strip() if isinstance(value, str) else value
|
||||
for key, value in user_input.items()
|
||||
}
|
||||
errors: dict[str, str] = {}
|
||||
display_name: str | None = None
|
||||
|
||||
serial = normalized[CONF_SERIAL]
|
||||
if not _validate_serial(serial):
|
||||
errors[CONF_SERIAL] = "invalid_serial"
|
||||
|
||||
errors.update(
|
||||
_validate_credentials(normalized[CONF_CERT_PEM], normalized[CONF_KEY_PEM])
|
||||
)
|
||||
|
||||
if not errors:
|
||||
await self.async_set_unique_id(serial)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
config = HarborCameraConfig(
|
||||
serial=serial,
|
||||
cert_pem=normalized[CONF_CERT_PEM],
|
||||
key_pem=normalized[CONF_KEY_PEM],
|
||||
ip_address=normalized[CONF_IP_ADDRESS],
|
||||
)
|
||||
try:
|
||||
display_name = await async_probe_camera(config)
|
||||
except TimeoutError:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
entry_data: dict[str, Any] = {
|
||||
CONF_SERIAL: serial,
|
||||
CONF_CERT_PEM: normalized[CONF_CERT_PEM],
|
||||
CONF_KEY_PEM: normalized[CONF_KEY_PEM],
|
||||
CONF_IP_ADDRESS: normalized[CONF_IP_ADDRESS],
|
||||
}
|
||||
|
||||
return self.async_create_entry(
|
||||
title=display_name or f"Camera {serial}",
|
||||
data=entry_data,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Constants for the Harbor integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "harbor"
|
||||
MANUFACTURER = "Harbor"
|
||||
MODEL = "Harbor Camera"
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
CONF_CERT_PEM = "cert_pem"
|
||||
CONF_KEY_PEM = "key_pem"
|
||||
CONF_SERIAL = "serial"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user