Merge branch 'dev' into add-librenms-integration

This commit is contained in:
Michael
2026-07-19 20:33:36 +02:00
committed by GitHub
803 changed files with 28805 additions and 10946 deletions
+1 -1
View File
@@ -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:
+3
View File
@@ -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
+2 -2
View File
@@ -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"
+48 -48
View File
@@ -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 }}
+1 -1
View File
@@ -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
+4
View File
@@ -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 -1
View File
@@ -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:
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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"]
}
+1 -15
View File
@@ -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,
}
+4 -15
View File
@@ -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"]
}
+10 -10
View File
@@ -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" },
+1 -1
View File
@@ -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",
+11 -2
View File
@@ -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
)
+32 -6
View File
@@ -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:
+9 -1
View File
@@ -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]]):
+29 -22
View File
@@ -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):
+12 -11
View File
@@ -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"
+15
View File
@@ -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",
+29 -2
View File
@@ -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,
)
+1 -1
View File
@@ -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][]].*",
+65
View File
@@ -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
View File
@@ -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
+12 -34
View File
@@ -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"
+91 -11
View File
@@ -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."""
+2 -2
View File
@@ -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."""
+3
View File
@@ -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:
+2 -3
View File
@@ -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,
)
+13
View File
@@ -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