mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Merge branch 'dev' into frenck/probatio-validation-engine
This commit is contained in:
+39
-15
@@ -55,10 +55,12 @@ env:
|
||||
# and some queries that work on MariaDB do not work on MySQL
|
||||
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mariadb:11.4.9','mysql:8.0.32']"
|
||||
# 12 is the oldest supported version
|
||||
# - 12.14 is the latest (as of 9 Feb 2023)
|
||||
# 15 is the latest version
|
||||
# - 15.2 is the latest (as of 9 Feb 2023)
|
||||
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
||||
# - 12.22 is the latest (as of 19 Aug 2026)
|
||||
# 15 is a supported version
|
||||
# - 15.19 is the latest (as of 19 Aug 2026)
|
||||
# 18 is the latest version
|
||||
# - 18.6 is the latest (as of 19 Aug 2026)
|
||||
POSTGRESQL_VERSIONS: "['postgres:12.22','postgres:15.19', 'postgres:18.6']"
|
||||
UV_CACHE_DIR: /tmp/uv-cache
|
||||
APT_CACHE_VERSION: 1
|
||||
SQLALCHEMY_WARN_20: 1
|
||||
@@ -472,6 +474,32 @@ jobs:
|
||||
run: |
|
||||
uv run --no-project python -m script.gen_copilot_instructions validate
|
||||
|
||||
gen-recorder-db-versions:
|
||||
name: Check recorder database versions
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- info
|
||||
# Only run on push to the dev branch; this job reaches out to endoflife.date, and
|
||||
# we do not want a new MariaDB/MySQL release to fail CI on PR runs or the rc/master
|
||||
# branches.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Check MariaDB and MySQL versions are up to date
|
||||
run: |
|
||||
uv run --no-project python -m script.gen_recorder_db_versions validate
|
||||
|
||||
dependency-review:
|
||||
name: Dependency review
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -833,9 +861,6 @@ jobs:
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/python.json"
|
||||
- name: Register pytest slow test problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Download pytest_buckets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -867,7 +892,8 @@ jobs:
|
||||
python3 -b -X dev -m pytest \
|
||||
-qq \
|
||||
--timeout=9 \
|
||||
--durations=10 \
|
||||
--durations=0 \
|
||||
--durations-min=1 \
|
||||
--numprocesses auto \
|
||||
--snapshot-details \
|
||||
--dist=loadfile \
|
||||
@@ -969,9 +995,6 @@ jobs:
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/python.json"
|
||||
- name: Register pytest slow test problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Install SQL Python libraries
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -1010,7 +1033,8 @@ jobs:
|
||||
--snapshot-details \
|
||||
${cov_params[@]} \
|
||||
-o console_output_style=count \
|
||||
--durations=10 \
|
||||
--durations=0 \
|
||||
--durations-min=10 \
|
||||
-p no:sugar \
|
||||
--exclude-warning-annotations \
|
||||
--dburl=mysql://root:password@127.0.0.1/homeassistant-test \
|
||||
@@ -1064,6 +1088,9 @@ jobs:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_PASSWORD: password
|
||||
# 18+ images default PGDATA to /var/lib/postgresql/<major>/docker,
|
||||
# which is not covered by the tmpfs below
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
options: >-
|
||||
--health-cmd="pg_isready -hlocalhost -Upostgres"
|
||||
--health-interval=5s --health-timeout=2s --health-retries=3
|
||||
@@ -1119,9 +1146,6 @@ jobs:
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/python.json"
|
||||
- name: Register pytest slow test problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Install SQL Python libraries
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -7,6 +7,7 @@ from homeassistant.const import CONF_API_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .coordinator import (
|
||||
@@ -27,7 +28,10 @@ PLATFORMS = [
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
||||
"""Set up Actron Air integration from a config entry."""
|
||||
|
||||
api = ActronAirAPI(refresh_token=entry.data[CONF_API_TOKEN])
|
||||
api = ActronAirAPI(
|
||||
refresh_token=entry.data[CONF_API_TOKEN],
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
systems: list[ActronAirSystemInfo] = []
|
||||
|
||||
try:
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
@@ -37,7 +38,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the initial step."""
|
||||
if self._api is None:
|
||||
LOGGER.debug("Initiating device authorization")
|
||||
self._api = ActronAirAPI()
|
||||
self._api = ActronAirAPI(session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
device_code_response = await self._api.request_device_code()
|
||||
except ActronAirAuthError as err:
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["actron-neo-api==0.5.13"]
|
||||
"requirements": ["actron-neo-api==0.5.14"]
|
||||
}
|
||||
|
||||
@@ -74,5 +74,5 @@ rules:
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
|
||||
@@ -132,8 +132,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
device_registry = dr.async_get(hass)
|
||||
self.previous_devices: set[str] = {
|
||||
identifier
|
||||
for device in device_registry.devices.get_devices_for_config_entry_id(
|
||||
entry.entry_id
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
)
|
||||
if device.entry_type != dr.DeviceEntryType.SERVICE
|
||||
for identifier_domain, identifier in device.identifiers
|
||||
|
||||
@@ -774,7 +774,7 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
removed_devices: set[str] = set()
|
||||
|
||||
# Get device list
|
||||
for device_entry in (*dev_reg.devices.values(), *dev_reg.child_devices.values()):
|
||||
for device_entry in (*dev_reg.devices, *dev_reg.child_devices):
|
||||
config_entry = hass.config_entries.async_get_entry(device_entry.config_entry_id)
|
||||
|
||||
if config_entry is None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, override
|
||||
from typing import Any, Final, override
|
||||
|
||||
from homeassistant.components.remote import (
|
||||
ATTR_ACTIVITY,
|
||||
@@ -16,14 +16,35 @@ from homeassistant.components.remote import (
|
||||
RemoteEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CONF_APP_NAME
|
||||
from .const import CONF_APP_NAME, DOMAIN
|
||||
from .entity import AndroidTVRemoteBaseEntity
|
||||
from .helpers import AndroidTVRemoteConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
PREFIX_SEPARATOR: Final[str] = ":"
|
||||
# Only direction prefixes are stripped; other colon conventions (e.g. text:) pass through to the library unchanged.
|
||||
VALID_PREFIXES: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"SHORT",
|
||||
"START_LONG",
|
||||
"END_LONG",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _parse_command(single_command: str) -> tuple[str, str | None]:
|
||||
"""Split an optional prefix from the key code."""
|
||||
prefix, separator, rest = single_command.partition(PREFIX_SEPARATOR)
|
||||
if separator:
|
||||
normalized = prefix.upper()
|
||||
if normalized in VALID_PREFIXES:
|
||||
return rest, normalized
|
||||
return single_command, None
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -105,10 +126,24 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity):
|
||||
|
||||
for _ in range(num_repeats):
|
||||
for single_command in command:
|
||||
key_code, direction = _parse_command(single_command)
|
||||
if direction is not None:
|
||||
if not key_code:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="empty_key_code",
|
||||
translation_placeholders={"command": single_command},
|
||||
)
|
||||
if hold_secs:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="direction_prefix_with_hold_secs",
|
||||
translation_placeholders={"command": single_command},
|
||||
)
|
||||
if hold_secs:
|
||||
self._send_key_command(single_command, "START_LONG")
|
||||
self._send_key_command(key_code, "START_LONG")
|
||||
await asyncio.sleep(hold_secs)
|
||||
self._send_key_command(single_command, "END_LONG")
|
||||
self._send_key_command(key_code, "END_LONG")
|
||||
else:
|
||||
self._send_key_command(single_command, "SHORT")
|
||||
self._send_key_command(key_code, direction or "SHORT")
|
||||
await asyncio.sleep(delay_secs)
|
||||
|
||||
@@ -56,6 +56,12 @@
|
||||
"connection_closed": {
|
||||
"message": "Connection to the Android TV device is closed"
|
||||
},
|
||||
"direction_prefix_with_hold_secs": {
|
||||
"message": "Command \"{command}\" combines a direction prefix with hold_secs; specify only one"
|
||||
},
|
||||
"empty_key_code": {
|
||||
"message": "Command \"{command}\" is missing a key code after the direction prefix"
|
||||
},
|
||||
"invalid_channel": {
|
||||
"message": "Channel must be numeric: {media_id}"
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [
|
||||
key="cook_time",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="cook_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
value_fn=lambda data: data.cook_time,
|
||||
@@ -62,6 +63,7 @@ SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [
|
||||
AnovaSensorEntityDescription(
|
||||
key="cook_time_remaining",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="cook_time_remaining",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
value_fn=lambda data: data.cook_time_remaining,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Ariston."""
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "ariston",
|
||||
"name": "Ariston",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "midea"
|
||||
}
|
||||
@@ -131,6 +131,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
f"Invalid Assist satellite entity id: {satellite_entity_id}"
|
||||
)
|
||||
|
||||
satellite_entity.async_set_context(call.context)
|
||||
|
||||
ask_question_args = {
|
||||
"question": call.data.get("question"),
|
||||
"question_media_id": call.data.get("question_media_id"),
|
||||
|
||||
@@ -7,7 +7,6 @@ import contextlib
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Literal, final, override
|
||||
|
||||
from hassil import Intents, recognize
|
||||
@@ -442,6 +441,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
start_stage: PipelineStage = PipelineStage.STT,
|
||||
end_stage: PipelineStage = PipelineStage.TTS,
|
||||
wake_word_phrase: str | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
|
||||
await self._cancel_running_pipeline()
|
||||
@@ -485,15 +486,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
|
||||
device_id = self.registry_entry.device_id if self.registry_entry else None
|
||||
|
||||
# Refresh context if necessary
|
||||
if (
|
||||
(self._context is None)
|
||||
or (self._context_set is None)
|
||||
or ((time.time() - self._context_set) > entity.CONTEXT_RECENT_TIME_SECONDS)
|
||||
):
|
||||
self.async_set_context(Context())
|
||||
|
||||
assert self._context is not None
|
||||
context = context or Context()
|
||||
self.async_set_context(context)
|
||||
|
||||
# Set entity state based on pipeline events
|
||||
self._run_has_tts = False
|
||||
@@ -511,7 +505,7 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
self.hass,
|
||||
async_pipeline_from_audio_stream(
|
||||
self.hass,
|
||||
context=self._context,
|
||||
context=context,
|
||||
event_callback=self._internal_on_pipeline_event,
|
||||
stt_metadata=stt.SpeechMetadata(
|
||||
language="", # set in async_pipeline_from_audio_stream
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""August util functions."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
|
||||
import aiohttp
|
||||
@@ -11,6 +11,7 @@ from yalexs.manager.const import ACTIVITY_UPDATE_INTERVAL
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import AugustData
|
||||
|
||||
@@ -61,7 +62,7 @@ def _activity_time_based(latest: Activity) -> Activity | None:
|
||||
"""Get the latest state of the sensor."""
|
||||
start = latest.activity_start_time
|
||||
end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION
|
||||
if start <= datetime.now() <= end: # pylint: disable=home-assistant-enforce-naive-now
|
||||
if start <= dt_util.naive_now() <= end:
|
||||
return latest
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Helpers to resolve client ID/secret."""
|
||||
|
||||
from html.parser import HTMLParser
|
||||
from http import HTTPStatus
|
||||
from ipaddress import ip_address
|
||||
import json
|
||||
import logging
|
||||
from typing import override
|
||||
from urllib.parse import ParseResult, urljoin, urlparse
|
||||
@@ -14,6 +16,9 @@ from homeassistant.util.network import is_local
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# We limit reads of a client_id page to the first 10kB.
|
||||
MAX_FETCH_BYTES = 10240
|
||||
|
||||
|
||||
async def verify_redirect_uri(
|
||||
hass: HomeAssistant, client_id: str, redirect_uri: str
|
||||
@@ -24,7 +29,10 @@ async def verify_redirect_uri(
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
redirect_parts = _parse_url(redirect_uri)
|
||||
try:
|
||||
redirect_parts = _parse_url(redirect_uri)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# Verify redirect url and client url have same scheme and domain.
|
||||
is_valid = (
|
||||
@@ -53,7 +61,15 @@ async def verify_redirect_uri(
|
||||
# IndieAuth 4.2.2 allows for redirect_uri to be on different domain
|
||||
# but needs to be specified in link tag when fetching `client_id`.
|
||||
redirect_uris = await fetch_redirect_uris(hass, client_id)
|
||||
return redirect_uri in redirect_uris
|
||||
if redirect_uri in redirect_uris:
|
||||
return True
|
||||
_LOGGER.debug(
|
||||
"redirect_uri %s is not among the advertised redirect uris %s for client_id %s",
|
||||
redirect_uri,
|
||||
redirect_uris,
|
||||
client_id,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class LinkTagParser(HTMLParser):
|
||||
@@ -63,7 +79,7 @@ class LinkTagParser(HTMLParser):
|
||||
"""Initialize a link tag parser."""
|
||||
super().__init__()
|
||||
self.rel = rel
|
||||
self.found: list[str | None] = []
|
||||
self.found: list[str] = []
|
||||
|
||||
@override
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
@@ -73,48 +89,115 @@ class LinkTagParser(HTMLParser):
|
||||
|
||||
attributes: dict[str, str | None] = dict(attrs)
|
||||
|
||||
if attributes.get("rel") == self.rel:
|
||||
self.found.append(attributes.get("href"))
|
||||
# Skip tags with a missing or empty href: urljoin resolves those to
|
||||
# the client_id URL itself instead of naming a redirect target.
|
||||
if attributes.get("rel") == self.rel and (href := attributes.get("href")):
|
||||
self.found.append(href)
|
||||
|
||||
|
||||
def _reject_json_constant(constant: str) -> None:
|
||||
"""Reject NaN/Infinity/-Infinity, which RFC 8259 does not allow."""
|
||||
raise ValueError(f"Invalid JSON constant: {constant}")
|
||||
|
||||
|
||||
def _is_valid_metadata_client_id(url: str) -> bool:
|
||||
"""Validate a client_id URL for the metadata-document fallback.
|
||||
|
||||
The client identifier URL must be https with a path component and no
|
||||
fragment (a bare trailing # counts as a fragment component). The remaining
|
||||
client identifier rules are enforced upstream by _parse_client_id.
|
||||
"""
|
||||
try:
|
||||
parts = urlparse(url)
|
||||
# urlparse defers port validation until the attribute is accessed.
|
||||
_ = parts.port
|
||||
except ValueError:
|
||||
return False
|
||||
return parts.scheme == "https" and bool(parts.path) and "#" not in url
|
||||
|
||||
|
||||
def _is_valid_metadata_redirect_uri(redirect_uri: str) -> bool:
|
||||
"""Validate a client ID metadata document redirect_uris entry.
|
||||
|
||||
Entries must be absolute, fragment-free URIs: a non-empty scheme (so
|
||||
private-use schemes like app:/callback stay valid) and no fragment per
|
||||
RFC 6749 3.1.2 (a bare trailing # counts as a fragment component).
|
||||
"""
|
||||
try:
|
||||
parts = urlparse(redirect_uri)
|
||||
# urlparse defers port validation until the attribute is accessed.
|
||||
_ = parts.port
|
||||
except ValueError:
|
||||
return False
|
||||
return bool(parts.scheme) and "#" not in redirect_uri
|
||||
|
||||
|
||||
async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]:
|
||||
"""Find link tag with redirect_uri values.
|
||||
"""Find the redirect_uri values that a client_id advertises.
|
||||
|
||||
We support two formats, checked in this order:
|
||||
|
||||
IndieAuth 4.2.2
|
||||
|
||||
The client SHOULD publish one or more <link> tags or Link HTTP headers with
|
||||
a rel attribute of redirect_uri at the client_id URL.
|
||||
|
||||
We limit to the first 10kB of the page.
|
||||
OAuth Client ID Metadata Document
|
||||
(draft-ietf-oauth-client-id-metadata-document)
|
||||
|
||||
The client_id URL returns a JSON document with a redirect_uris array. As we
|
||||
advertise client_id_metadata_document_supported in the authorization server
|
||||
metadata, we fall back to this format when no link tags are found.
|
||||
|
||||
We read roughly the first 10kB of the page and a fetch error yields no
|
||||
redirect uris.
|
||||
|
||||
We do not implement extracting redirect uris from headers.
|
||||
"""
|
||||
parser = LinkTagParser("redirect_uri")
|
||||
chunks = 0
|
||||
body: bytes = b""
|
||||
status: int | None = None
|
||||
redirected = False
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp,
|
||||
):
|
||||
status = resp.status
|
||||
redirected = bool(resp.history)
|
||||
async for data in resp.content.iter_chunked(1024):
|
||||
parser.feed(data.decode())
|
||||
chunks += 1
|
||||
body += data
|
||||
|
||||
if chunks == 10:
|
||||
if len(body) >= MAX_FETCH_BYTES:
|
||||
break
|
||||
|
||||
except TimeoutError:
|
||||
_LOGGER.error("Timeout while looking up redirect_uri %s", url)
|
||||
return []
|
||||
except aiohttp.client_exceptions.ClientSSLError:
|
||||
_LOGGER.error("SSL error while looking up redirect_uri %s", url)
|
||||
return []
|
||||
except aiohttp.client_exceptions.ClientOSError as ex:
|
||||
_LOGGER.error("OS error while looking up redirect_uri %s: %s", url, ex.strerror)
|
||||
return []
|
||||
except aiohttp.client_exceptions.ClientConnectionError:
|
||||
_LOGGER.error(
|
||||
"Low level connection error while looking up redirect_uri %s", url
|
||||
)
|
||||
return []
|
||||
except aiohttp.client_exceptions.ClientError:
|
||||
_LOGGER.error("Unknown error while looking up redirect_uri %s", url)
|
||||
return []
|
||||
|
||||
if redirect_uris := _parse_link_tag_redirect_uris(url, body):
|
||||
return redirect_uris
|
||||
|
||||
return _parse_metadata_document_redirect_uris(url, body, status, redirected)
|
||||
|
||||
|
||||
def _parse_link_tag_redirect_uris(url: str, body: bytes) -> list[str]:
|
||||
"""Find <link rel="redirect_uri"> values in the client_id page body."""
|
||||
parser = LinkTagParser("redirect_uri")
|
||||
parser.feed(body.decode(errors="replace"))
|
||||
|
||||
# Authorization endpoints verifying that a redirect_uri is allowed for use
|
||||
# by a client MUST look for an exact match of the given redirect_uri in the
|
||||
@@ -123,6 +206,77 @@ async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]:
|
||||
return [urljoin(url, found) for found in parser.found]
|
||||
|
||||
|
||||
def _parse_metadata_document_redirect_uris(
|
||||
url: str, body: bytes, status: int | None, redirected: bool
|
||||
) -> list[str]:
|
||||
"""Parse the client_id page body as an OAuth Client ID Metadata Document.
|
||||
|
||||
Per draft-ietf-oauth-client-id-metadata-document the document only counts
|
||||
when the client_id URL is https with a path and no fragment, the response
|
||||
was a direct 200 (not redirected), the document's client_id round-trips,
|
||||
and every redirect_uris entry is an absolute, fragment-free URI matched
|
||||
exactly. The url and its document are client-controlled and fetched
|
||||
unauthenticated, so rejections log at DEBUG (higher levels would be a
|
||||
log-flood vector).
|
||||
"""
|
||||
# A body at the read cap may be truncated; a truncated prefix must not be
|
||||
# trusted even if it happens to be parseable.
|
||||
if (
|
||||
len(body) >= MAX_FETCH_BYTES
|
||||
or status != HTTPStatus.OK
|
||||
or redirected
|
||||
or not _is_valid_metadata_client_id(url)
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Not treating %s as a client ID metadata document: body length %s,"
|
||||
" status %s, redirected %s (client_id must be a fragment-free https"
|
||||
" URL with a path)",
|
||||
url,
|
||||
len(body),
|
||||
status,
|
||||
redirected,
|
||||
)
|
||||
return []
|
||||
|
||||
try:
|
||||
# Strict decode (RFC 8259 requires UTF-8): the link tag parser's
|
||||
# lenient replacement decode would mask invalid bytes as U+FFFD.
|
||||
document = json.loads(body.decode(), parse_constant=_reject_json_constant)
|
||||
except UnicodeDecodeError:
|
||||
_LOGGER.debug("Client ID metadata document at %s is not valid UTF-8", url)
|
||||
return []
|
||||
except ValueError:
|
||||
_LOGGER.debug("Client ID metadata document at %s is not valid JSON", url)
|
||||
return []
|
||||
|
||||
if not isinstance(document, dict):
|
||||
_LOGGER.debug("Client ID metadata document at %s is not a JSON object", url)
|
||||
return []
|
||||
|
||||
if document.get("client_id") != url:
|
||||
_LOGGER.debug(
|
||||
"Client ID metadata document at %s client_id does not match the"
|
||||
" document URL",
|
||||
url,
|
||||
)
|
||||
return []
|
||||
|
||||
# redirect_uris entries are returned unmodified for RFC 6749 exact matching
|
||||
# rather than resolving relative references.
|
||||
redirect_uris = document.get("redirect_uris")
|
||||
if not isinstance(redirect_uris, list) or not all(
|
||||
isinstance(redirect_uri, str) and _is_valid_metadata_redirect_uri(redirect_uri)
|
||||
for redirect_uri in redirect_uris
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Client ID metadata document at %s has missing or invalid redirect_uris",
|
||||
url,
|
||||
)
|
||||
return []
|
||||
|
||||
return redirect_uris
|
||||
|
||||
|
||||
def verify_client_id(client_id: str) -> bool:
|
||||
"""Verify that the client id is valid."""
|
||||
try:
|
||||
|
||||
@@ -137,12 +137,11 @@ class WellKnownOAuthInfoView(HomeAssistantView):
|
||||
"authorization_endpoint": f"{url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{url_prefix}/auth/revoke",
|
||||
# Home Assistant already accepts URL-based client_ids via
|
||||
# IndieAuth without prior registration, which is compatible with
|
||||
# draft-ietf-oauth-client-id-metadata-document. This flag
|
||||
# advertises that support to encourage clients to use it. The
|
||||
# metadata document is not actually fetched as IndieAuth doesn't
|
||||
# require it.
|
||||
# Home Assistant accepts URL-based client_ids via IndieAuth without
|
||||
# prior registration, and discovers allowed redirect URIs from link
|
||||
# tags or a Client ID Metadata Document served at the client_id URL.
|
||||
# This flag advertises that support
|
||||
# (draft-ietf-oauth-client-id-metadata-document).
|
||||
"client_id_metadata_document_supported": True,
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": (
|
||||
|
||||
@@ -55,9 +55,7 @@ async def async_setup_entry(
|
||||
# As it has to be removed from the device on the app.
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
devices = device_registry.devices.get_devices_for_config_entry_id(
|
||||
config_entry.entry_id
|
||||
)
|
||||
devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)
|
||||
for device in devices:
|
||||
if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in {
|
||||
remote.serial_number for remote in remotes
|
||||
|
||||
@@ -185,8 +185,8 @@ class BeoWebsocket(BeoBase):
|
||||
# Get remote devices connected to the device from Home Assistant
|
||||
device_serial_numbers = [
|
||||
device.serial_number
|
||||
for device in device_registry.devices.get_devices_for_config_entry_id(
|
||||
self.entry.entry_id
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, self.entry.entry_id
|
||||
)
|
||||
if device.serial_number is not None
|
||||
and device.model == BeoModel.BEOREMOTE_ONE
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"bleak-retry-connector==4.6.3",
|
||||
"bluetooth-adapters==2.4.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"bluetooth-data-tools==1.29.21",
|
||||
"dbus-fast==5.0.22",
|
||||
"habluetooth==6.26.5"
|
||||
"habluetooth==6.26.7"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_NICKNAME,
|
||||
@@ -240,11 +241,13 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
|
||||
self.source = None
|
||||
if start_datetime := playing_info.get("startDateTime"):
|
||||
start_datetime = datetime.fromisoformat(start_datetime)
|
||||
current_datetime = datetime.now().replace(tzinfo=start_datetime.tzinfo) # pylint: disable=home-assistant-enforce-naive-now
|
||||
self.media_position = int(
|
||||
(current_datetime - start_datetime).total_seconds()
|
||||
)
|
||||
self.media_position_updated_at = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
|
||||
if start_datetime.tzinfo is None:
|
||||
start_datetime = start_datetime.replace(
|
||||
tzinfo=dt_util.get_default_time_zone()
|
||||
)
|
||||
now = dt_util.utcnow()
|
||||
self.media_position = int((now - start_datetime).total_seconds())
|
||||
self.media_position_updated_at = now
|
||||
else:
|
||||
self.media_position = None
|
||||
self.media_position_updated_at = None
|
||||
|
||||
@@ -8,6 +8,7 @@ import broadlink as blk
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.helpers import event
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -31,7 +32,7 @@ class BroadlinkHeartbeat:
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the heartbeat."""
|
||||
if self._unsubscribe is None:
|
||||
await self.async_heartbeat(dt.datetime.now()) # pylint: disable=home-assistant-enforce-naive-now
|
||||
await self.async_heartbeat(dt_util.utcnow())
|
||||
self._unsubscribe = event.async_track_time_interval(
|
||||
self._hass, self.async_heartbeat, self.HEARTBEAT_INTERVAL
|
||||
)
|
||||
|
||||
@@ -14,6 +14,9 @@ CONF_TIMEFRAME = "timeframe"
|
||||
SUPPORTED_COUNTRY_CODES = ["NL", "BE"]
|
||||
DEFAULT_COUNTRY = "NL"
|
||||
|
||||
SERVICE_TIME_ZONE = "Europe/Amsterdam"
|
||||
"""Time zone of the buienradar.nl service, which updates around local midnight."""
|
||||
|
||||
SCHEDULE_OK = 10
|
||||
"""Schedule next call after (minutes)."""
|
||||
SCHEDULE_NOK = 2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Shared utilities for different supported platforms."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -34,7 +34,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_track_point_in_utc_time
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DEFAULT_TIMEOUT, SCHEDULE_NOK, SCHEDULE_OK
|
||||
from .const import DEFAULT_TIMEOUT, SCHEDULE_NOK, SCHEDULE_OK, SERVICE_TIME_ZONE
|
||||
|
||||
__all__ = ["BrData"]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -158,7 +158,12 @@ class BrData:
|
||||
|
||||
_LOGGER.debug("Buienradar parsed data: %s", result)
|
||||
if result.get(SUCCESS) is not True:
|
||||
if int(datetime.now().strftime("%H")) > 0: # pylint: disable=home-assistant-enforce-naive-now
|
||||
# buienradar.nl updates its forecast for the next day between 00:00
|
||||
# and 01:00 CE(S)T and often serves nothing during that hour, so the
|
||||
# warning is only meaningful outside it. The hour that decides this
|
||||
# is the one at the service, not in the user's configured time zone.
|
||||
service_tz = await dt_util.async_get_time_zone(SERVICE_TIME_ZONE)
|
||||
if service_tz is None or dt_util.utcnow().astimezone(service_tz).hour > 0:
|
||||
_LOGGER.warning(
|
||||
"Unable to parse data from Buienradar. (Msg: %s)",
|
||||
result.get(MESSAGE),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Library for working with CalDAV api."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
import logging
|
||||
|
||||
@@ -8,7 +7,7 @@ from caldav.lib.error import DAVError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import WARNED_CALENDARS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,9 +44,7 @@ async def async_get_calendars(
|
||||
calendars, needs_warning = await hass.async_add_executor_job(_get_calendars)
|
||||
|
||||
if needs_warning:
|
||||
warned_calendars: set[tuple[str, str]] = hass.data.setdefault(
|
||||
DOMAIN, {}
|
||||
).setdefault("warned_calendars", set())
|
||||
warned_calendars = hass.data.setdefault(WARNED_CALENDARS, set())
|
||||
for url, name, comp in needs_warning:
|
||||
# This workaround and warning can be removed when we upgrade to caldav 3.0
|
||||
if (url, comp) not in warned_calendars:
|
||||
|
||||
@@ -2,5 +2,12 @@
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN: Final = "caldav"
|
||||
TIMEOUT: Final = 30
|
||||
|
||||
# Calendars we have already warned about, keyed by (url, component). This is
|
||||
# deliberately not stored on a config entry: the warning is per CalDAV server
|
||||
# and must survive reloads, and the same server may back more than one entry.
|
||||
WARNED_CALENDARS: HassKey[set[tuple[str, str]]] = HassKey(f"{DOMAIN}_warned_calendars")
|
||||
|
||||
@@ -71,7 +71,9 @@ class CentriConnectCoordinator(DataUpdateCoordinator[Tank]):
|
||||
try:
|
||||
tank_data = await self.api_client.async_get_tank_data()
|
||||
except CentriConnectError as err:
|
||||
raise UpdateFailed("Could not fetch device info") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN, translation_key="entry_setup_failed"
|
||||
) from err
|
||||
self.device_info = CentriConnectDeviceInfo(
|
||||
device_id=tank_data.device_id,
|
||||
device_name=tank_data.device_name,
|
||||
@@ -87,7 +89,19 @@ class CentriConnectCoordinator(DataUpdateCoordinator[Tank]):
|
||||
try:
|
||||
state = await self.api_client.async_get_tank_data()
|
||||
except CentriConnectConnectionError as err:
|
||||
raise UpdateFailed(f"Error communicating with device: {err}") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={
|
||||
"error": repr(err),
|
||||
},
|
||||
) from err
|
||||
except CentriConnectError as err:
|
||||
raise UpdateFailed(f"Unexpected response: {err}") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unexpected_response",
|
||||
translation_placeholders={
|
||||
"error": repr(err),
|
||||
},
|
||||
) from err
|
||||
return state
|
||||
|
||||
@@ -68,7 +68,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
|
||||
@@ -65,5 +65,16 @@
|
||||
"name": "Tank size"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"communication_error": {
|
||||
"message": "Error communicating with device: {error}"
|
||||
},
|
||||
"entry_setup_failed": {
|
||||
"message": "Could not fetch device info"
|
||||
},
|
||||
"unexpected_response": {
|
||||
"message": "Unexpected response: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
|
||||
# State reporting is reported as a property on entities.
|
||||
# So when we change it, we need to sync all entities.
|
||||
await self.async_sync_entities()
|
||||
await self._async_sync_entities_unless_relink_needed()
|
||||
return
|
||||
|
||||
# Nothing to do if no Alexa related things have changed
|
||||
@@ -396,7 +396,14 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
):
|
||||
return
|
||||
|
||||
await self.async_sync_entities()
|
||||
await self._async_sync_entities_unless_relink_needed()
|
||||
|
||||
async def _async_sync_entities_unless_relink_needed(self) -> None:
|
||||
"""Sync entities, tolerating an account with no linked Alexa skill."""
|
||||
try:
|
||||
await self.async_sync_entities()
|
||||
except alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink:
|
||||
await self.set_authorized(False)
|
||||
|
||||
@callback
|
||||
def _async_exposed_entities_updated(self) -> None:
|
||||
|
||||
@@ -79,6 +79,10 @@ _CLOUD_ERRORS: dict[
|
||||
HTTPStatus.BAD_GATEWAY,
|
||||
"Unable to reach the Home Assistant Cloud.",
|
||||
),
|
||||
auth.AuthTimeoutError: (
|
||||
HTTPStatus.GATEWAY_TIMEOUT,
|
||||
"Authentication timed out.",
|
||||
),
|
||||
aiohttp.ClientError: (
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
"Error making internal request",
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==2.2.0", "openai==2.45.0"],
|
||||
"requirements": ["hass-nabucasa==2.3.0", "openai==2.45.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/compensation",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["numpy==2.3.2"]
|
||||
"requirements": ["numpy==2.5.2"]
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ def websocket_list_composite_splits(
|
||||
None,
|
||||
),
|
||||
}
|
||||
for composite_id, devices in registry.devices.get_composite_splits().items()
|
||||
for composite_id, devices in registry._devices.get_composite_splits().items() # noqa: SLF001
|
||||
},
|
||||
)
|
||||
|
||||
@@ -92,8 +92,7 @@ def websocket_list_devices(
|
||||
inner = b",".join(
|
||||
[
|
||||
entry.json_repr
|
||||
for container in (registry.devices, registry.child_devices)
|
||||
for entry in container.values()
|
||||
for entry in (*registry.devices, *registry.child_devices)
|
||||
if entry.json_repr is not None
|
||||
]
|
||||
)
|
||||
@@ -179,8 +178,27 @@ def websocket_update_device(
|
||||
# Convert labels to a set
|
||||
msg["labels"] = set(msg["labels"])
|
||||
|
||||
device_id = msg["device_id"]
|
||||
|
||||
# A composite device id has no single underlying device to update; reject it.
|
||||
if (
|
||||
registry.async_get(
|
||||
device_id, include_main_devices=False, include_child_devices=False
|
||||
)
|
||||
is not None
|
||||
):
|
||||
connection.send_error(
|
||||
msg_id, websocket_api.ERR_NOT_ALLOWED, "Cannot update a composite device"
|
||||
)
|
||||
return
|
||||
if (
|
||||
device := registry.async_get(device_id, include_composite_devices=False)
|
||||
) is None:
|
||||
connection.send_error(msg_id, websocket_api.ERR_NOT_FOUND, "Device not found")
|
||||
return
|
||||
|
||||
entry: dr.AnyDeviceEntry | None
|
||||
if msg["device_id"] in registry.child_devices:
|
||||
if isinstance(device, dr.ChildDeviceEntry):
|
||||
entry = registry.async_update_child_device(**msg)
|
||||
else:
|
||||
entry = registry.async_update_device(**msg)
|
||||
@@ -207,10 +225,16 @@ async def _async_remove_device(
|
||||
device_id = msg["device_id"]
|
||||
|
||||
# A composite device id has no single underlying device to remove; reject it.
|
||||
if registry.async_is_composite_device_id(device_id):
|
||||
if (
|
||||
registry.async_get(
|
||||
device_id, include_main_devices=False, include_child_devices=False
|
||||
)
|
||||
is not None
|
||||
):
|
||||
raise HomeAssistantError("Cannot remove a composite device")
|
||||
|
||||
if (device_entry := registry.async_get(device_id)) is None:
|
||||
if (
|
||||
device_entry := registry.async_get(device_id, include_composite_devices=False)
|
||||
) is None:
|
||||
raise HomeAssistantError("Unknown device")
|
||||
|
||||
if (
|
||||
|
||||
@@ -137,7 +137,9 @@ def async_describe_events(
|
||||
@callback
|
||||
def async_describe_deconz_alarm_event(event: Event) -> dict[str, str]:
|
||||
"""Describe deCONZ logbook alarm event."""
|
||||
if device := device_registry.devices.get(event.data[ATTR_DEVICE_ID]):
|
||||
if device := device_registry.async_get(
|
||||
event.data[ATTR_DEVICE_ID], include_child_devices=False
|
||||
):
|
||||
deconz_alarm_event = _get_deconz_event_from_device(hass, device)
|
||||
name = deconz_alarm_event.device.name
|
||||
else:
|
||||
@@ -153,7 +155,9 @@ def async_describe_events(
|
||||
@callback
|
||||
def async_describe_deconz_event(event: Event) -> dict[str, str]:
|
||||
"""Describe deCONZ logbook event."""
|
||||
if device := device_registry.devices.get(event.data[ATTR_DEVICE_ID]):
|
||||
if device := device_registry.async_get(
|
||||
event.data[ATTR_DEVICE_ID], include_child_devices=False
|
||||
):
|
||||
deconz_event = _get_deconz_event_from_device(hass, device)
|
||||
name = deconz_event.device.name
|
||||
else:
|
||||
|
||||
@@ -181,8 +181,8 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None:
|
||||
entities_to_be_removed = []
|
||||
devices_to_be_removed = [
|
||||
entry.id
|
||||
for entry in device_registry.devices.get_devices_for_config_entry_id(
|
||||
hub.config_entry.entry_id
|
||||
for entry in dr.async_entries_for_config_entry(
|
||||
device_registry, hub.config_entry.entry_id
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -489,7 +489,8 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
old_timestamp: datetime,
|
||||
) -> None:
|
||||
"""Handle the sensor state changes."""
|
||||
if not _is_decimal_state(old_value):
|
||||
recovered_from_invalid = not _is_decimal_state(old_value)
|
||||
if recovered_from_invalid:
|
||||
if self._last_valid_state_time:
|
||||
old_value = self._last_valid_state_time[0]
|
||||
old_timestamp = self._last_valid_state_time[1]
|
||||
@@ -550,6 +551,12 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
"%s: Dropping sample as source total_increasing sensor decreased",
|
||||
self.entity_id,
|
||||
)
|
||||
if recovered_from_invalid:
|
||||
# Reset while recovering from an invalid source: re-baseline
|
||||
# and report zero so the entity doesn't stay stuck unavailable.
|
||||
self._state_list = []
|
||||
self._last_valid_state_time = (new_state.state, new_timestamp)
|
||||
self._write_native_value(Decimal(0))
|
||||
return
|
||||
|
||||
# add latest derivative to the window list
|
||||
|
||||
@@ -240,7 +240,7 @@ async def async_get_device_automations(
|
||||
entity_registry = er.async_get(hass)
|
||||
domain_devices: dict[str, set[str]] = {}
|
||||
device_entities_domains: dict[str, set[str]] = {}
|
||||
match_device_ids = set(device_ids or device_registry.devices)
|
||||
match_device_ids = set(device_ids or device_registry._devices) # noqa: SLF001
|
||||
combined_results: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
for device_id in match_device_ids:
|
||||
|
||||
@@ -53,7 +53,10 @@ def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str:
|
||||
knows the current device id, not the removed composite id.
|
||||
"""
|
||||
device_registry = dr.async_get(hass)
|
||||
if device_id in device_registry.devices:
|
||||
if (
|
||||
device_registry.async_get(device_id, include_composite_devices=False)
|
||||
is not None
|
||||
):
|
||||
return device_id
|
||||
if not (
|
||||
split_devices := device_registry.async_get_devices_for_composite_device_id(
|
||||
|
||||
@@ -75,6 +75,7 @@ async def async_setup_entry(
|
||||
async_add_entities(missing)
|
||||
|
||||
restore_entities()
|
||||
new_device_callback()
|
||||
entry.async_on_unload(
|
||||
coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback)
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ from homeassistant.helpers import (
|
||||
integration_platform,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.json import (
|
||||
ExtendedJSONEncoder,
|
||||
find_paths_unserializable_data,
|
||||
@@ -62,7 +61,7 @@ class DiagnosticsPlatformData:
|
||||
)
|
||||
device_diagnostics: (
|
||||
Callable[
|
||||
[HomeAssistant, ConfigEntry, DeviceEntry],
|
||||
[HomeAssistant, ConfigEntry, dr.AnyDeviceEntry],
|
||||
Coroutine[Any, Any, Mapping[str, Any]],
|
||||
]
|
||||
| None
|
||||
@@ -100,9 +99,12 @@ class DiagnosticsProtocol(Protocol):
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
async def async_get_device_diagnostics(
|
||||
self, hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry
|
||||
self, hass: HomeAssistant, config_entry: ConfigEntry, device: dr.AnyDeviceEntry
|
||||
) -> Mapping[str, Any]:
|
||||
"""Return diagnostics for a device."""
|
||||
"""Return diagnostics for a device.
|
||||
|
||||
Only integrations that register child devices can receive a child device.
|
||||
"""
|
||||
|
||||
|
||||
@callback
|
||||
@@ -314,10 +316,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
|
||||
if info.device_diagnostics is None:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
# A device's diagnostics may be requested for a child device, but the
|
||||
# callback is currently typed for a main device. Ignoring the mismatch until
|
||||
# DiagnosticsPlatformData.device_diagnostics is widened to accept AnyDeviceEntry.
|
||||
data = await info.device_diagnostics(hass, config_entry, device) # type: ignore[arg-type]
|
||||
data = await info.device_diagnostics(hass, config_entry, device)
|
||||
return await _async_get_json_file_response(
|
||||
hass, data, data_issues, filename, config_entry.domain, d_id, sub_id
|
||||
)
|
||||
|
||||
@@ -494,7 +494,7 @@ class Thermostat(ClimateEntity):
|
||||
"id": device.id,
|
||||
"name_by_user": device.name_by_user or device.name,
|
||||
}
|
||||
for device in device_registry.devices.values()
|
||||
for device in device_registry.devices
|
||||
for sensor_info in sensors_info
|
||||
if device.name == sensor_info["name"]
|
||||
and any(identifier[0] == DOMAIN for identifier in device.identifiers)
|
||||
@@ -830,7 +830,7 @@ class Thermostat(ClimateEntity):
|
||||
return sorted(
|
||||
[
|
||||
device.name_by_user or device.name
|
||||
for device in device_registry.devices.values()
|
||||
for device in device_registry.devices
|
||||
for sensor_name in sensor_names
|
||||
if device.name == sensor_name
|
||||
and any(identifier[0] == DOMAIN for identifier in device.identifiers)
|
||||
|
||||
@@ -29,6 +29,8 @@ from .const import API_URL, DOMAIN, INTEGRATION_NAME, SCOPE
|
||||
# does not end with space or dot
|
||||
VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$")
|
||||
|
||||
DELETION_POLL_INTERVAL = 5
|
||||
|
||||
|
||||
class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth):
|
||||
"""Authentication implementation used during config flow, without refresh.
|
||||
@@ -276,4 +278,4 @@ class OAuth2FlowHandler(
|
||||
][0]
|
||||
if self._data["system"].function_webhook_quotas["used"] == 0:
|
||||
break
|
||||
await asyncio.sleep(5)
|
||||
await asyncio.sleep(DELETION_POLL_INTERVAL)
|
||||
|
||||
@@ -4,7 +4,7 @@ import copy
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import ClientResponse
|
||||
from aiohttp import ClientError, ClientResponse
|
||||
from pyenphase.envoy import Envoy
|
||||
from pyenphase.exceptions import EnvoyError
|
||||
|
||||
@@ -92,6 +92,10 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
|
||||
"code": response.status,
|
||||
}
|
||||
)
|
||||
except ClientError as err:
|
||||
fixture_data[f"{end_point}_log"] = {
|
||||
"Error": f"Aiohttp Client error {type(err).__name__ if not hasattr(err, 'status') else err.status}"
|
||||
}
|
||||
except EnvoyError as err:
|
||||
fixture_data[f"{end_point}_log"] = {"Error": repr(err)}
|
||||
return fixture_data
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==3.2.1"],
|
||||
"requirements": ["pyenphase==4.0.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -21,7 +21,7 @@ from pyenphase import (
|
||||
EnvoySystemConsumption,
|
||||
EnvoySystemProduction,
|
||||
)
|
||||
from pyenphase.const import PHASENAMES
|
||||
from pyenphase.const import PHASENAMES, SupportedFeatures
|
||||
from pyenphase.models.acb import ACBChargeStatus, ACBSleepState
|
||||
from pyenphase.models.meters import (
|
||||
CtMeterStatus,
|
||||
@@ -383,7 +383,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Envoy CT sensor entity."""
|
||||
|
||||
value_fn: Callable[
|
||||
[EnvoyMeterData],
|
||||
[EnvoyMeterData | None],
|
||||
int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None,
|
||||
]
|
||||
on_phase: str | None = None
|
||||
@@ -586,7 +586,9 @@ CT_SENSORS = (
|
||||
translation_key=(translation_key if translation_key != "" else key),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
value_fn=lambda ct: (
|
||||
0 if ct is None or ct.status_flags is None else len(ct.status_flags)
|
||||
),
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
@@ -1020,7 +1022,9 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up envoy sensor platform."""
|
||||
coordinator = config_entry.runtime_data
|
||||
envoy_data = coordinator.envoy.data
|
||||
envoy = coordinator.envoy
|
||||
assert envoy is not None
|
||||
envoy_data = envoy.data
|
||||
assert envoy_data is not None
|
||||
_LOGGER.debug("Envoy data: %s", envoy_data)
|
||||
|
||||
@@ -1028,39 +1032,57 @@ async def async_setup_entry(
|
||||
EnvoyProductionEntity(coordinator, description)
|
||||
for description in PRODUCTION_SENSORS
|
||||
]
|
||||
if envoy_data.system_consumption:
|
||||
# add unconditionally if TOTAL_CONSUMPTION is available to overcome
|
||||
# None value at startup caused by envoy fw issues
|
||||
if envoy.supported_features & SupportedFeatures.TOTAL_CONSUMPTION:
|
||||
entities.extend(
|
||||
EnvoyConsumptionEntity(coordinator, description)
|
||||
for description in CONSUMPTION_SENSORS
|
||||
)
|
||||
if envoy_data.system_net_consumption:
|
||||
# add unconditionally if NET_CONSUMPTION is available to overcome
|
||||
# None value at startup caused by envoy fw issues
|
||||
if envoy.supported_features & SupportedFeatures.NET_CONSUMPTION:
|
||||
entities.extend(
|
||||
EnvoyNetConsumptionEntity(coordinator, description)
|
||||
for description in NET_CONSUMPTION_SENSORS
|
||||
)
|
||||
# For each production phase reported add production entities
|
||||
if envoy_data.system_production_phases:
|
||||
# if PRODUCTION is available and phases detected even if None
|
||||
# to overcome None value at startup caused by envoy fw issues
|
||||
if envoy.active_phase_count and (
|
||||
envoy.supported_features & SupportedFeatures.PRODUCTION
|
||||
):
|
||||
entities.extend(
|
||||
EnvoyProductionPhaseEntity(coordinator, description)
|
||||
for use_phase, phase in envoy_data.system_production_phases.items()
|
||||
for index, use_phase in enumerate(PHASENAMES)
|
||||
for description in PRODUCTION_PHASE_SENSORS[use_phase]
|
||||
if phase is not None
|
||||
if index < (envoy.phase_count if envoy.phase_count > 1 else 0)
|
||||
)
|
||||
# For each consumption phase reported add consumption entities
|
||||
if envoy_data.system_consumption_phases:
|
||||
# if TOTAL_CONSUMPTION is available and phases detected even if None
|
||||
# to overcome None value at startup caused by envoy fw issues
|
||||
if (
|
||||
envoy.active_phase_count
|
||||
and envoy.phase_count > 1
|
||||
and (envoy.supported_features & SupportedFeatures.TOTAL_CONSUMPTION)
|
||||
):
|
||||
entities.extend(
|
||||
EnvoyConsumptionPhaseEntity(coordinator, description)
|
||||
for use_phase, phase in envoy_data.system_consumption_phases.items()
|
||||
for index, use_phase in enumerate(PHASENAMES)
|
||||
for description in CONSUMPTION_PHASE_SENSORS[use_phase]
|
||||
if phase is not None
|
||||
if index < (envoy.phase_count if envoy.phase_count > 1 else 0)
|
||||
)
|
||||
# For each net_consumption phase reported add consumption entities
|
||||
if envoy_data.system_net_consumption_phases:
|
||||
# if NET_CONSUMPTION is available and phases detected even if None
|
||||
# to overcome None value at startup caused by envoy fw issues
|
||||
if envoy.active_phase_count and (
|
||||
envoy.supported_features & SupportedFeatures.NET_CONSUMPTION
|
||||
):
|
||||
entities.extend(
|
||||
EnvoyNetConsumptionPhaseEntity(coordinator, description)
|
||||
for use_phase, phase in envoy_data.system_net_consumption_phases.items()
|
||||
for index, use_phase in enumerate(PHASENAMES)
|
||||
for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase]
|
||||
if phase is not None
|
||||
if index < (envoy.phase_count if envoy.phase_count > 1 else 0)
|
||||
)
|
||||
# Add Current Transformer entities
|
||||
if envoy_data.ctmeters:
|
||||
@@ -1181,8 +1203,8 @@ class EnvoyProductionEntity(EnvoySystemSensorEntity):
|
||||
@override
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the state of the sensor."""
|
||||
system_production = self.data.system_production
|
||||
assert system_production is not None
|
||||
if (system_production := self.data.system_production) is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(system_production)
|
||||
|
||||
|
||||
@@ -1195,8 +1217,8 @@ class EnvoyConsumptionEntity(EnvoySystemSensorEntity):
|
||||
@override
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the state of the sensor."""
|
||||
system_consumption = self.data.system_consumption
|
||||
assert system_consumption is not None
|
||||
if (system_consumption := self.data.system_consumption) is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(system_consumption)
|
||||
|
||||
|
||||
@@ -1209,8 +1231,8 @@ class EnvoyNetConsumptionEntity(EnvoySystemSensorEntity):
|
||||
@override
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the state of the sensor."""
|
||||
system_net_consumption = self.data.system_net_consumption
|
||||
assert system_net_consumption is not None
|
||||
if (system_net_consumption := self.data.system_net_consumption) is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(system_net_consumption)
|
||||
|
||||
|
||||
@@ -1225,8 +1247,11 @@ class EnvoyProductionPhaseEntity(EnvoySystemSensorEntity):
|
||||
"""Return the state of the sensor."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.entity_description.on_phase
|
||||
assert self.data.system_production_phases
|
||||
|
||||
if self.data.system_production_phases is None:
|
||||
return None
|
||||
if self.entity_description.on_phase not in self.data.system_production_phases:
|
||||
return None
|
||||
if (
|
||||
system_production := self.data.system_production_phases[
|
||||
self.entity_description.on_phase
|
||||
@@ -1247,8 +1272,11 @@ class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity):
|
||||
"""Return the state of the sensor."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.entity_description.on_phase
|
||||
assert self.data.system_consumption_phases
|
||||
|
||||
if self.data.system_consumption_phases is None:
|
||||
return None
|
||||
if self.entity_description.on_phase not in self.data.system_consumption_phases:
|
||||
return None
|
||||
if (
|
||||
system_consumption := self.data.system_consumption_phases[
|
||||
self.entity_description.on_phase
|
||||
@@ -1269,8 +1297,14 @@ class EnvoyNetConsumptionPhaseEntity(EnvoySystemSensorEntity):
|
||||
"""Return the state of the sensor."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.entity_description.on_phase
|
||||
assert self.data.system_net_consumption_phases
|
||||
|
||||
if self.data.system_net_consumption_phases is None:
|
||||
return None
|
||||
if (
|
||||
self.entity_description.on_phase
|
||||
not in self.data.system_net_consumption_phases
|
||||
):
|
||||
return None
|
||||
if (
|
||||
system_net_consumption := self.data.system_net_consumption_phases[
|
||||
self.entity_description.on_phase
|
||||
@@ -1293,6 +1327,8 @@ class EnvoyCTEntity(EnvoySystemSensorEntity):
|
||||
"""Return the state of the CT sensor."""
|
||||
if (cttype := self.entity_description.cttype) not in self.data.ctmeters:
|
||||
return None
|
||||
if self.data.ctmeters[cttype] is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(self.data.ctmeters[cttype])
|
||||
|
||||
|
||||
@@ -1315,6 +1351,8 @@ class EnvoyCTPhaseEntity(EnvoySystemSensorEntity):
|
||||
cttype
|
||||
]:
|
||||
return None
|
||||
if self.data.ctmeters_phases[cttype][phase] is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(
|
||||
self.data.ctmeters_phases[cttype][phase]
|
||||
)
|
||||
|
||||
@@ -522,7 +522,9 @@ class RuntimeEntryData:
|
||||
"""
|
||||
self.available = False
|
||||
if self.bluetooth_device:
|
||||
self.bluetooth_device.available = False
|
||||
# Fails pending BLE slot waiters and clears the dead
|
||||
# session's allocations in addition to closing the gate.
|
||||
self.bluetooth_device.async_set_unavailable()
|
||||
# Make a copy since calling the disconnect callbacks
|
||||
# may also try to discard/remove themselves.
|
||||
for disconnect_cb in self.disconnect_callbacks.copy():
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.6.1",
|
||||
"aioesphomeapi==45.12.0",
|
||||
"esphome-dashboard-api==1.4.0",
|
||||
"bleak-esphome==3.9.7"
|
||||
"bleak-esphome==4.0.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Coordinator for FYTA integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_EXPIRATION, DOMAIN
|
||||
|
||||
@@ -54,10 +55,7 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]):
|
||||
) -> dict[int, Plant]:
|
||||
"""Fetch data from API endpoint."""
|
||||
|
||||
if (
|
||||
self.fyta.expiration is None
|
||||
or self.fyta.expiration.timestamp() < datetime.now().timestamp() # pylint: disable=home-assistant-enforce-naive-now
|
||||
):
|
||||
if self.fyta.expiration is None or self.fyta.expiration < dt_util.now():
|
||||
await self.renew_authentication()
|
||||
|
||||
try:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Final, override
|
||||
|
||||
@@ -17,6 +16,7 @@ from homeassistant.components.image import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .coordinator import FytaConfigEntry, FytaCoordinator
|
||||
from .entity import FytaPlantEntity
|
||||
@@ -119,5 +119,5 @@ class FytaPlantImageEntity(FytaPlantEntity, ImageEntity):
|
||||
|
||||
if url != self._attr_image_url:
|
||||
self._cached_image = None
|
||||
self._attr_image_last_updated = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
return url
|
||||
|
||||
@@ -214,11 +214,41 @@ DESCRIPTIONS = (
|
||||
char=EventHistory.error,
|
||||
get=lambda x: (
|
||||
x.error_code.name.lower()
|
||||
if x and isinstance(x.error_code, EventHistory.error.enum)
|
||||
if x is not None and isinstance(x.error_code, EventHistory.error.enum)
|
||||
else None
|
||||
),
|
||||
options=[member.name.lower() for member in EventHistory.error.enum],
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key="aqua_contour_activation_reason",
|
||||
translation_key="activation_reason",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
char=AquaContourWatering.activation_reason,
|
||||
get=lambda x: (
|
||||
x.name.lower()
|
||||
if isinstance(x, AquaContourWatering.activation_reason.enum)
|
||||
else None
|
||||
),
|
||||
options=[
|
||||
member.name.lower() for member in AquaContourWatering.activation_reason.enum
|
||||
],
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key="aqua_contour_skipped_reason",
|
||||
translation_key="skipped_reason",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
char=AquaContourWatering.skipped_reason,
|
||||
get=lambda x: (
|
||||
x.name.lower()
|
||||
if isinstance(x, AquaContourWatering.skipped_reason.enum)
|
||||
else None
|
||||
),
|
||||
options=[
|
||||
member.name.lower() for member in AquaContourWatering.skipped_reason.enum
|
||||
],
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key="aqua_contour_error_timestamp",
|
||||
translation_key="error_timestamp",
|
||||
|
||||
@@ -162,6 +162,30 @@
|
||||
"sensor_type": {
|
||||
"name": "Sensor type"
|
||||
},
|
||||
"skipped_reason": {
|
||||
"name": "Skipped reason",
|
||||
"state": {
|
||||
"battery_empty": "Battery empty",
|
||||
"charging_cable_plugged": "Charging cable plugged",
|
||||
"contour_data_invalid": "Contour data invalid",
|
||||
"contour_not_active": "Contour not active",
|
||||
"contour_not_enabled_for_position": "Contour not enabled for position",
|
||||
"humidity_sensor": "Humidity sensor",
|
||||
"irrigation_control_changed": "Irrigation control changed",
|
||||
"manual_mode": "Manual mode",
|
||||
"no_water": "No water",
|
||||
"none": "Inactive",
|
||||
"operational_mode_changed": "Operational mode changed",
|
||||
"other_schedule_with_same_start_time": "Other schedule with same start time",
|
||||
"position_changed": "Position changed",
|
||||
"rain_pause": "Rain pause",
|
||||
"rain_sensor": "Rain sensor",
|
||||
"rotation_sensor_error": "Rotation sensor error",
|
||||
"sprinkler_motor_error": "Sprinkler motor error",
|
||||
"valve_motor_error": "Valve motor error",
|
||||
"watering_already_active": "Watering already active"
|
||||
}
|
||||
},
|
||||
"spray_current_distance": {
|
||||
"name": "Current distance"
|
||||
},
|
||||
|
||||
@@ -42,9 +42,7 @@ async def async_setup_entry(
|
||||
dev_reg = dr.async_get(hass)
|
||||
dev_ids = {
|
||||
identifier[1]
|
||||
for device in dev_reg.devices.get_devices_for_config_entry_id(
|
||||
config_entry.entry_id
|
||||
)
|
||||
for device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)
|
||||
for identifier in device.identifiers
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,14 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
|
||||
return await hass.async_add_executor_job(shutil.which, "go2rtc")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SessionInfo:
|
||||
"""Session info."""
|
||||
|
||||
ws_client: Go2RtcWsClient
|
||||
camera: Camera
|
||||
|
||||
|
||||
class WebRTCProvider(CameraWebRTCProvider):
|
||||
"""WebRTC provider."""
|
||||
|
||||
@@ -276,7 +284,7 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
self._url = url
|
||||
self._session = session
|
||||
self._rest_client = rest_client
|
||||
self._sessions: dict[str, Go2RtcWsClient] = {}
|
||||
self._sessions: dict[str, _SessionInfo] = {}
|
||||
self._supported_schemes: set[str] = set()
|
||||
|
||||
@property
|
||||
@@ -310,9 +318,13 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
send_message(WebRTCError("go2rtc_webrtc_offer_failed", str(err)))
|
||||
return
|
||||
|
||||
self._sessions[session_id] = ws_client = Go2RtcWsClient(
|
||||
ws_client = Go2RtcWsClient(
|
||||
self._session, self._url, source=get_camera_identifier(camera)
|
||||
)
|
||||
self._sessions[session_id] = _SessionInfo(
|
||||
ws_client=ws_client,
|
||||
camera=camera,
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_messages(message: ReceiveMessages) -> None:
|
||||
@@ -338,8 +350,8 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
) -> None:
|
||||
"""Handle the WebRTC candidate."""
|
||||
|
||||
if ws_client := self._sessions.get(session_id):
|
||||
await ws_client.send(WebRTCCandidate(candidate.candidate))
|
||||
if session_info := self._sessions.get(session_id):
|
||||
await session_info.ws_client.send(WebRTCCandidate(candidate.candidate))
|
||||
else:
|
||||
_LOGGER.debug("Unknown session %s. Ignoring candidate", session_id)
|
||||
|
||||
@@ -347,8 +359,8 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
@override
|
||||
def async_close_session(self, session_id: str) -> None:
|
||||
"""Close the session."""
|
||||
ws_client = self._sessions.pop(session_id)
|
||||
self._hass.async_create_task(ws_client.close())
|
||||
if session_info := self._sessions.pop(session_id, None):
|
||||
self._hass.async_create_task(session_info.ws_client.close())
|
||||
|
||||
@override
|
||||
async def async_get_image(
|
||||
@@ -366,7 +378,7 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
async def _update_stream_source(self, camera: Camera) -> None:
|
||||
"""Update the stream source in go2rtc config if needed."""
|
||||
if not (stream_source := await camera.stream_source()):
|
||||
await self.teardown()
|
||||
await self._close_camera_sessions(camera)
|
||||
raise HomeAssistantError("Camera has no stream source")
|
||||
|
||||
if camera.platform.platform_name == "generic":
|
||||
@@ -376,7 +388,7 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
stream_source = "ffmpeg:" + stream_source
|
||||
|
||||
if not self.async_is_supported(stream_source):
|
||||
await self.teardown()
|
||||
await self._close_camera_sessions(camera)
|
||||
raise HomeAssistantError("Stream source is not supported by go2rtc")
|
||||
|
||||
camera_prefs = await get_dynamic_camera_stream_settings(
|
||||
@@ -440,11 +452,20 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
else:
|
||||
await self._rest_client.preload.disable(identifier)
|
||||
|
||||
async def _close_camera_sessions(self, camera: Camera) -> None:
|
||||
for session_id in list(self._sessions):
|
||||
session_info = self._sessions.get(session_id)
|
||||
if session_info is None or session_info.camera != camera:
|
||||
continue
|
||||
# Unregister before closing, as closing yields to the event loop
|
||||
del self._sessions[session_id]
|
||||
await session_info.ws_client.close()
|
||||
|
||||
async def teardown(self) -> None:
|
||||
"""Tear down the provider."""
|
||||
for ws_client in self._sessions.values():
|
||||
await ws_client.close()
|
||||
self._sessions.clear()
|
||||
while self._sessions:
|
||||
_, session_info = self._sessions.popitem()
|
||||
await session_info.ws_client.close()
|
||||
|
||||
@override
|
||||
async def async_register_camera(
|
||||
@@ -460,6 +481,7 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
camera: Camera,
|
||||
) -> None:
|
||||
"""Will be called when the provider is unregistered for a camera."""
|
||||
await self._close_camera_sessions(camera)
|
||||
identifier = get_camera_identifier(camera)
|
||||
if identifier in await self._rest_client.preload.list():
|
||||
await self._rest_client.preload.disable(identifier)
|
||||
|
||||
@@ -48,7 +48,7 @@ async def async_setup_entry(
|
||||
dev_reg = dr.async_get(hass)
|
||||
dev_ids = {
|
||||
identifier[1]
|
||||
for device in dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id)
|
||||
for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
|
||||
for identifier in device.identifiers
|
||||
}
|
||||
if not dev_ids:
|
||||
|
||||
@@ -265,6 +265,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
mode=service.data.get(ATTR_ALL),
|
||||
object_id=object_id,
|
||||
order=None,
|
||||
context=service.context,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -272,6 +273,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
_LOGGER.warning("%s:Group '%s' doesn't exist!", service.service, object_id)
|
||||
return
|
||||
|
||||
group.async_set_context(service.context)
|
||||
|
||||
# update group
|
||||
if service.service == SERVICE_SET:
|
||||
need_update = False
|
||||
|
||||
@@ -5,20 +5,18 @@ from typing import Any, override
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_CURRENT_POSITION,
|
||||
ATTR_CURRENT_TILT_POSITION,
|
||||
ATTR_POSITION,
|
||||
ATTR_TILT_POSITION,
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
CoverEntityStateAttribute,
|
||||
CoverState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_ENTITIES,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
@@ -32,6 +30,7 @@ from homeassistant.const import (
|
||||
SERVICE_STOP_COVER_TILT,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityStateAttribute,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
@@ -148,7 +147,7 @@ class CoverGroup(GroupEntity, CoverEntity):
|
||||
values.discard(entity_id)
|
||||
return
|
||||
|
||||
features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
features = new_state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0)
|
||||
|
||||
if features & (CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE):
|
||||
self._covers[KEY_OPEN_CLOSE].add(entity_id)
|
||||
@@ -313,14 +312,14 @@ class CoverGroup(GroupEntity, CoverEntity):
|
||||
all_position_states = [self.hass.states.get(x) for x in position_covers]
|
||||
position_states: list[State] = list(filter(None, all_position_states))
|
||||
self._attr_current_cover_position = reduce_attribute(
|
||||
position_states, ATTR_CURRENT_POSITION
|
||||
position_states, CoverEntityStateAttribute.CURRENT_POSITION
|
||||
)
|
||||
|
||||
tilt_covers = self._tilts[KEY_POSITION]
|
||||
all_tilt_states = [self.hass.states.get(x) for x in tilt_covers]
|
||||
tilt_states: list[State] = list(filter(None, all_tilt_states))
|
||||
self._attr_current_cover_tilt_position = reduce_attribute(
|
||||
tilt_states, ATTR_CURRENT_TILT_POSITION
|
||||
tilt_states, CoverEntityStateAttribute.CURRENT_TILT_POSITION
|
||||
)
|
||||
|
||||
supported_features = CoverEntityFeature(0)
|
||||
|
||||
@@ -6,14 +6,15 @@ import logging
|
||||
from typing import Any, override
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ASSUMED_STATE,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_GROUP_ENTITIES,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
EntityCapabilityAttribute,
|
||||
EntityStateAttribute,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Context,
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
@@ -39,7 +40,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class GroupEntity(Entity):
|
||||
"""Representation of a Group of entities."""
|
||||
|
||||
_unrecorded_attributes = frozenset({ATTR_ENTITY_ID, ATTR_GROUP_ENTITIES})
|
||||
_unrecorded_attributes = frozenset(
|
||||
{ATTR_ENTITY_ID, EntityCapabilityAttribute.GROUP_ENTITIES}
|
||||
)
|
||||
|
||||
_attr_should_poll = False
|
||||
_entity_ids: list[str]
|
||||
@@ -127,7 +130,7 @@ class GroupEntity(Entity):
|
||||
for entity_id in self._entity_ids:
|
||||
if (state := self.hass.states.get(entity_id)) is None:
|
||||
continue
|
||||
if state.attributes.get(ATTR_ASSUMED_STATE):
|
||||
if state.attributes.get(EntityStateAttribute.ASSUMED_STATE):
|
||||
self._attr_assumed_state = True
|
||||
return
|
||||
|
||||
@@ -231,6 +234,7 @@ class Group(Entity):
|
||||
mode: bool | None,
|
||||
object_id: str | None,
|
||||
order: int | None,
|
||||
context: Context | None,
|
||||
) -> Group:
|
||||
"""Initialize a group.
|
||||
|
||||
@@ -247,6 +251,9 @@ class Group(Entity):
|
||||
order=order,
|
||||
)
|
||||
|
||||
if context is not None:
|
||||
group.async_set_context(context)
|
||||
|
||||
# If called before the platform async_setup is called (test cases)
|
||||
await async_get_component(hass).async_add_entities([group])
|
||||
return group
|
||||
@@ -430,7 +437,9 @@ class Group(Entity):
|
||||
domain = new_state.domain
|
||||
state = new_state.state
|
||||
registry = self._registry
|
||||
self._assumed[entity_id] = bool(new_state.attributes.get(ATTR_ASSUMED_STATE))
|
||||
self._assumed[entity_id] = bool(
|
||||
new_state.attributes.get(EntityStateAttribute.ASSUMED_STATE)
|
||||
)
|
||||
|
||||
if domain not in registry.on_states_by_domain:
|
||||
# Handle the group of a group case
|
||||
@@ -462,11 +471,12 @@ class Group(Entity):
|
||||
return
|
||||
|
||||
if tr_state is None or (
|
||||
self._assumed_state and not tr_state.attributes.get(ATTR_ASSUMED_STATE)
|
||||
self._assumed_state
|
||||
and not tr_state.attributes.get(EntityStateAttribute.ASSUMED_STATE)
|
||||
):
|
||||
self._assumed_state = self.mode(self._assumed.values())
|
||||
|
||||
elif tr_state.attributes.get(ATTR_ASSUMED_STATE):
|
||||
elif tr_state.attributes.get(EntityStateAttribute.ASSUMED_STATE):
|
||||
self._assumed_state = True
|
||||
|
||||
num_on_states = len(self._on_states)
|
||||
|
||||
@@ -6,22 +6,21 @@ from typing import Any, override
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.event import (
|
||||
ATTR_EVENT_TYPE,
|
||||
ATTR_EVENT_TYPES,
|
||||
DOMAIN as EVENT_DOMAIN,
|
||||
PLATFORM_SCHEMA as EVENT_PLATFORM_SCHEMA,
|
||||
EventEntity,
|
||||
EventEntityCapabilityAttribute,
|
||||
EventEntityStateAttribute,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_FRIENDLY_NAME,
|
||||
CONF_ENTITIES,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityStateAttribute,
|
||||
)
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
@@ -142,16 +141,20 @@ class EventGroup(GroupEntity, EventEntity):
|
||||
and old_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
and (new_state := event.data["new_state"])
|
||||
and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
and (event_type := new_state.attributes.get(ATTR_EVENT_TYPE))
|
||||
and (
|
||||
event_type := new_state.attributes.get(
|
||||
EventEntityStateAttribute.EVENT_TYPE
|
||||
)
|
||||
)
|
||||
):
|
||||
event_attributes = new_state.attributes.copy()
|
||||
|
||||
# We should not propagate the event properties as
|
||||
# fired event attributes.
|
||||
del event_attributes[ATTR_EVENT_TYPE]
|
||||
del event_attributes[ATTR_EVENT_TYPES]
|
||||
event_attributes.pop(ATTR_DEVICE_CLASS, None)
|
||||
event_attributes.pop(ATTR_FRIENDLY_NAME, None)
|
||||
del event_attributes[EventEntityStateAttribute.EVENT_TYPE]
|
||||
del event_attributes[EventEntityCapabilityAttribute.EVENT_TYPES]
|
||||
event_attributes.pop(EntityStateAttribute.DEVICE_CLASS, None)
|
||||
event_attributes.pop(EntityStateAttribute.FRIENDLY_NAME, None)
|
||||
|
||||
# Fire the group event
|
||||
self._trigger_event(event_type, event_attributes)
|
||||
@@ -185,7 +188,8 @@ class EventGroup(GroupEntity, EventEntity):
|
||||
self._attr_event_types = list(
|
||||
set(
|
||||
itertools.chain.from_iterable(
|
||||
state.attributes.get(ATTR_EVENT_TYPES, []) for state in states
|
||||
state.attributes.get(EventEntityCapabilityAttribute.EVENT_TYPES, [])
|
||||
for state in states
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ from homeassistant.components.fan import (
|
||||
ATTR_DIRECTION,
|
||||
ATTR_OSCILLATING,
|
||||
ATTR_PERCENTAGE,
|
||||
ATTR_PERCENTAGE_STEP,
|
||||
DOMAIN as FAN_DOMAIN,
|
||||
PLATFORM_SCHEMA as FAN_PLATFORM_SCHEMA,
|
||||
SERVICE_OSCILLATE,
|
||||
@@ -21,17 +20,18 @@ from homeassistant.components.fan import (
|
||||
SERVICE_TURN_ON,
|
||||
FanEntity,
|
||||
FanEntityFeature,
|
||||
FanEntityStateAttribute,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_ENTITIES,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityStateAttribute,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
@@ -166,7 +166,9 @@ class FanGroup(GroupEntity, FanEntity):
|
||||
for values in self._fans.values():
|
||||
values.discard(entity_id)
|
||||
else:
|
||||
features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
features = new_state.attributes.get(
|
||||
EntityStateAttribute.SUPPORTED_FEATURES, 0
|
||||
)
|
||||
for feature in SUPPORTED_FLAGS:
|
||||
if features & feature:
|
||||
self._fans[feature].add(entity_id)
|
||||
@@ -286,14 +288,25 @@ class FanGroup(GroupEntity, FanEntity):
|
||||
percentage_states = self._async_states_by_support_flag(
|
||||
FanEntityFeature.SET_SPEED
|
||||
)
|
||||
self._percentage = reduce_attribute(percentage_states, ATTR_PERCENTAGE)
|
||||
self._percentage = reduce_attribute(
|
||||
percentage_states, FanEntityStateAttribute.PERCENTAGE
|
||||
)
|
||||
if (
|
||||
percentage_states
|
||||
and percentage_states[0].attributes.get(ATTR_PERCENTAGE_STEP)
|
||||
and attribute_equal(percentage_states, ATTR_PERCENTAGE_STEP)
|
||||
and percentage_states[0].attributes.get(
|
||||
FanEntityStateAttribute.PERCENTAGE_STEP
|
||||
)
|
||||
and attribute_equal(
|
||||
percentage_states, FanEntityStateAttribute.PERCENTAGE_STEP
|
||||
)
|
||||
):
|
||||
self._speed_count = (
|
||||
round(100 / percentage_states[0].attributes[ATTR_PERCENTAGE_STEP])
|
||||
round(
|
||||
100
|
||||
/ percentage_states[0].attributes[
|
||||
FanEntityStateAttribute.PERCENTAGE_STEP
|
||||
]
|
||||
)
|
||||
or 100
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -10,31 +10,27 @@ import voluptuous as vol
|
||||
from homeassistant.components import light
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_EFFECT,
|
||||
ATTR_EFFECT_LIST,
|
||||
ATTR_FLASH,
|
||||
ATTR_HS_COLOR,
|
||||
ATTR_MAX_COLOR_TEMP_KELVIN,
|
||||
ATTR_MIN_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_RGBW_COLOR,
|
||||
ATTR_RGBWW_COLOR,
|
||||
ATTR_SUPPORTED_COLOR_MODES,
|
||||
ATTR_TRANSITION,
|
||||
ATTR_WHITE,
|
||||
ATTR_XY_COLOR,
|
||||
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
LightEntityCapabilityAttribute,
|
||||
LightEntityFeature,
|
||||
LightEntityStateAttribute,
|
||||
filter_supported_color_modes,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_ENTITIES,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
@@ -43,6 +39,7 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityStateAttribute,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
@@ -227,36 +224,46 @@ class LightGroup(GroupEntity, LightEntity):
|
||||
self._attr_is_on = self.mode(state.state == STATE_ON for state in states)
|
||||
|
||||
self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states)
|
||||
self._attr_brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS)
|
||||
self._attr_brightness = reduce_attribute(
|
||||
on_states, LightEntityStateAttribute.BRIGHTNESS
|
||||
)
|
||||
|
||||
self._attr_hs_color = reduce_attribute(
|
||||
on_states, ATTR_HS_COLOR, reduce=mean_circle
|
||||
on_states, LightEntityStateAttribute.HS_COLOR, reduce=mean_circle
|
||||
)
|
||||
self._attr_rgb_color = reduce_attribute(
|
||||
on_states, ATTR_RGB_COLOR, reduce=mean_tuple
|
||||
on_states, LightEntityStateAttribute.RGB_COLOR, reduce=mean_tuple
|
||||
)
|
||||
self._attr_rgbw_color = reduce_attribute(
|
||||
on_states, ATTR_RGBW_COLOR, reduce=mean_tuple
|
||||
on_states, LightEntityStateAttribute.RGBW_COLOR, reduce=mean_tuple
|
||||
)
|
||||
self._attr_rgbww_color = reduce_attribute(
|
||||
on_states, ATTR_RGBWW_COLOR, reduce=mean_tuple
|
||||
on_states, LightEntityStateAttribute.RGBWW_COLOR, reduce=mean_tuple
|
||||
)
|
||||
self._attr_xy_color = reduce_attribute(
|
||||
on_states, ATTR_XY_COLOR, reduce=mean_tuple
|
||||
on_states, LightEntityStateAttribute.XY_COLOR, reduce=mean_tuple
|
||||
)
|
||||
|
||||
self._attr_color_temp_kelvin = reduce_attribute(
|
||||
on_states, ATTR_COLOR_TEMP_KELVIN
|
||||
on_states, LightEntityStateAttribute.COLOR_TEMP_KELVIN
|
||||
)
|
||||
self._attr_min_color_temp_kelvin = reduce_attribute(
|
||||
states, ATTR_MIN_COLOR_TEMP_KELVIN, default=2000, reduce=min
|
||||
states,
|
||||
LightEntityCapabilityAttribute.MIN_COLOR_TEMP_KELVIN,
|
||||
default=2000,
|
||||
reduce=min,
|
||||
)
|
||||
self._attr_max_color_temp_kelvin = reduce_attribute(
|
||||
states, ATTR_MAX_COLOR_TEMP_KELVIN, default=6500, reduce=max
|
||||
states,
|
||||
LightEntityCapabilityAttribute.MAX_COLOR_TEMP_KELVIN,
|
||||
default=6500,
|
||||
reduce=max,
|
||||
)
|
||||
|
||||
self._attr_effect_list = None
|
||||
all_effect_lists = list(find_state_attributes(states, ATTR_EFFECT_LIST))
|
||||
all_effect_lists = list(
|
||||
find_state_attributes(states, LightEntityCapabilityAttribute.EFFECT_LIST)
|
||||
)
|
||||
if all_effect_lists:
|
||||
# Merge all effects from all effect_lists with a union merge.
|
||||
self._attr_effect_list = list(set().union(*all_effect_lists))
|
||||
@@ -266,7 +273,9 @@ class LightGroup(GroupEntity, LightEntity):
|
||||
self._attr_effect_list.insert(0, "None")
|
||||
|
||||
self._attr_effect = None
|
||||
all_effects = list(find_state_attributes(on_states, ATTR_EFFECT))
|
||||
all_effects = list(
|
||||
find_state_attributes(on_states, LightEntityStateAttribute.EFFECT)
|
||||
)
|
||||
if all_effects:
|
||||
# Report the most common effect.
|
||||
effects_count = Counter(itertools.chain(all_effects))
|
||||
@@ -274,7 +283,9 @@ class LightGroup(GroupEntity, LightEntity):
|
||||
|
||||
supported_color_modes = {ColorMode.ONOFF}
|
||||
all_supported_color_modes = list(
|
||||
find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES)
|
||||
find_state_attributes(
|
||||
states, LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES
|
||||
)
|
||||
)
|
||||
if all_supported_color_modes:
|
||||
# Merge all color modes.
|
||||
@@ -284,7 +295,9 @@ class LightGroup(GroupEntity, LightEntity):
|
||||
self._attr_supported_color_modes = supported_color_modes
|
||||
|
||||
self._attr_color_mode = ColorMode.UNKNOWN
|
||||
all_color_modes = list(find_state_attributes(on_states, ATTR_COLOR_MODE))
|
||||
all_color_modes = list(
|
||||
find_state_attributes(on_states, LightEntityStateAttribute.COLOR_MODE)
|
||||
)
|
||||
if all_color_modes:
|
||||
# Report the most common color mode, select brightness and onoff last
|
||||
color_mode_count = Counter(itertools.chain(all_color_modes))
|
||||
@@ -304,7 +317,9 @@ class LightGroup(GroupEntity, LightEntity):
|
||||
self._attr_color_mode = next(iter(supported_color_modes))
|
||||
|
||||
self._attr_supported_features = LightEntityFeature(0)
|
||||
for support in find_state_attributes(states, ATTR_SUPPORTED_FEATURES):
|
||||
for support in find_state_attributes(
|
||||
states, EntityStateAttribute.SUPPORTED_FEATURES
|
||||
):
|
||||
# Merge supported features by emulating support for every feature
|
||||
# we find.
|
||||
self._attr_supported_features |= support
|
||||
|
||||
@@ -19,13 +19,13 @@ from homeassistant.components.media_player import (
|
||||
SERVICE_PLAY_MEDIA,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerEntityStateAttribute,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_ENTITIES,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
@@ -42,6 +42,7 @@ from homeassistant.const import (
|
||||
SERVICE_VOLUME_SET,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityStateAttribute,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
@@ -174,7 +175,9 @@ class MediaPlayerGroup(MediaPlayerEntity):
|
||||
players.discard(entity_id)
|
||||
return
|
||||
|
||||
new_features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
new_features = new_state.attributes.get(
|
||||
EntityStateAttribute.SUPPORTED_FEATURES, 0
|
||||
)
|
||||
if new_features & MediaPlayerEntityFeature.CLEAR_PLAYLIST:
|
||||
self._features[KEY_CLEAR_PLAYLIST].add(entity_id)
|
||||
else:
|
||||
@@ -441,7 +444,9 @@ class MediaPlayerGroup(MediaPlayerEntity):
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Turn volume up for media player(s)."""
|
||||
for entity in self._features[KEY_VOLUME]:
|
||||
volume_level = self.hass.states.get(entity).attributes["volume_level"] # type: ignore[union-attr]
|
||||
volume_level = self.hass.states.get(entity).attributes[ # type: ignore[union-attr]
|
||||
MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL
|
||||
]
|
||||
if volume_level < 1:
|
||||
await self.async_set_volume_level(min(1, volume_level + 0.1))
|
||||
|
||||
@@ -449,7 +454,9 @@ class MediaPlayerGroup(MediaPlayerEntity):
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Turn volume down for media player(s)."""
|
||||
for entity in self._features[KEY_VOLUME]:
|
||||
volume_level = self.hass.states.get(entity).attributes["volume_level"] # type: ignore[union-attr]
|
||||
volume_level = self.hass.states.get(entity).attributes[ # type: ignore[union-attr]
|
||||
MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL
|
||||
]
|
||||
if volume_level > 0:
|
||||
await self.async_set_volume_level(max(0, volume_level - 0.1))
|
||||
|
||||
|
||||
@@ -21,11 +21,11 @@ from homeassistant.components.notify import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_ACTION,
|
||||
CONF_ENTITIES,
|
||||
CONF_SERVICE,
|
||||
STATE_UNAVAILABLE,
|
||||
EntityStateAttribute,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
@@ -213,7 +213,7 @@ class NotifyGroup(GroupEntity, NotifyEntity):
|
||||
state = self.hass.states.get(entity_id)
|
||||
if (
|
||||
state is None
|
||||
or not state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
or not state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0)
|
||||
& NotifyEntityFeature.TITLE
|
||||
):
|
||||
self._attr_supported_features &= ~NotifyEntityFeature.TITLE
|
||||
|
||||
@@ -36,6 +36,7 @@ from homeassistant.const import (
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityStateAttribute,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -401,7 +402,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
states.append(state.state)
|
||||
try:
|
||||
numeric_state = float(state.state)
|
||||
uom = state.attributes.get("unit_of_measurement")
|
||||
uom = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT)
|
||||
|
||||
# Convert the state to the native unit of
|
||||
# measurement when we have valid units
|
||||
@@ -455,7 +456,9 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
entity_id,
|
||||
state.state,
|
||||
self.device_class,
|
||||
state.attributes.get("unit_of_measurement"),
|
||||
state.attributes.get(
|
||||
EntityStateAttribute.UNIT_OF_MEASUREMENT
|
||||
),
|
||||
self.entity_id,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -5,18 +5,17 @@ from typing import Any, override
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.valve import (
|
||||
ATTR_CURRENT_POSITION,
|
||||
ATTR_POSITION,
|
||||
DOMAIN as VALVE_DOMAIN,
|
||||
PLATFORM_SCHEMA as VALVE_PLATFORM_SCHEMA,
|
||||
ValveEntity,
|
||||
ValveEntityFeature,
|
||||
ValveEntityStateAttribute,
|
||||
ValveState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_ENTITIES,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
@@ -26,6 +25,7 @@ from homeassistant.const import (
|
||||
SERVICE_STOP_VALVE,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityStateAttribute,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
@@ -136,7 +136,7 @@ class ValveGroup(GroupEntity, ValveEntity):
|
||||
values.discard(entity_id)
|
||||
return
|
||||
|
||||
features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
features = new_state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0)
|
||||
|
||||
if features & (ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE):
|
||||
self._valves[KEY_OPEN_CLOSE].add(entity_id)
|
||||
@@ -233,7 +233,10 @@ class ValveGroup(GroupEntity, ValveEntity):
|
||||
self._attr_reports_position = False
|
||||
self._update_assumed_state_from_members()
|
||||
for state in states:
|
||||
if state.attributes.get(ATTR_CURRENT_POSITION) is not None:
|
||||
if (
|
||||
state.attributes.get(ValveEntityStateAttribute.CURRENT_POSITION)
|
||||
is not None
|
||||
):
|
||||
self._attr_reports_position = True
|
||||
if state.state == ValveState.OPEN:
|
||||
self._attr_is_closed = False
|
||||
@@ -255,7 +258,7 @@ class ValveGroup(GroupEntity, ValveEntity):
|
||||
self._attr_is_closed = None
|
||||
|
||||
self._attr_current_valve_position = reduce_attribute(
|
||||
states, ATTR_CURRENT_POSITION
|
||||
states, ValveEntityStateAttribute.CURRENT_POSITION
|
||||
)
|
||||
|
||||
supported_features = ValveEntityFeature(0)
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["guntamatic==1.9.3"]
|
||||
"requirements": ["guntamatic==1.11.1"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ from aiohasupervisor.models import (
|
||||
)
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.auth.models import RefreshToken, User
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.components import frontend
|
||||
from homeassistant.components.homeassistant import async_set_stop_handler
|
||||
from homeassistant.components.onboarding import async_is_onboarded
|
||||
@@ -49,7 +49,7 @@ from . import ( # noqa: F401
|
||||
update,
|
||||
)
|
||||
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState
|
||||
from .addon_panel import async_setup_addon_panel
|
||||
from .addon_panel import async_setup_addon_panel, async_setup_addon_panel_coordinator
|
||||
from .auth import async_setup_auth_view
|
||||
from .config import HassioConfigStore, StoredHassioConfig
|
||||
from .config_entry import async_get_hassio_entry
|
||||
@@ -412,11 +412,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
translation_key="supervisor_update_pending",
|
||||
)
|
||||
|
||||
# Get or create a refresh token for the Supervisor user
|
||||
if user.refresh_tokens:
|
||||
refresh_token = list(user.refresh_tokens.values())[0]
|
||||
else:
|
||||
refresh_token = await hass.auth.async_create_refresh_token(user)
|
||||
# Supervisor authenticates through its dedicated Unix socket.
|
||||
for refresh_token in list(user.refresh_tokens.values()):
|
||||
hass.auth.async_remove_refresh_token(refresh_token)
|
||||
|
||||
# Set up coordinators — these can raise ConfigEntryNotReady.
|
||||
# Register listeners only after all refreshes succeed to avoid accumulation
|
||||
@@ -426,6 +424,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data[MAIN_COORDINATOR] = coordinator
|
||||
entry.async_on_unload(async_setup_addon_panel_coordinator(hass, coordinator))
|
||||
|
||||
jobs_coordinator = SupervisorJobsCoordinator(hass, entry)
|
||||
await jobs_coordinator.async_config_entry_first_refresh()
|
||||
@@ -491,7 +490,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
entry.async_on_unload(hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config))
|
||||
|
||||
async def update_hass_api(refresh_token: RefreshToken) -> None:
|
||||
async def update_hass_api() -> None:
|
||||
"""Update Home Assistant API data on Hass.io."""
|
||||
# hass.config.api is always set here: hassio depends on http, and the
|
||||
# http integration assigns hass.config.api during its async_setup.
|
||||
@@ -499,7 +498,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
options = HomeAssistantOptions(
|
||||
ssl=hass.config.api.use_ssl,
|
||||
port=hass.config.api.port,
|
||||
refresh_token=refresh_token.token,
|
||||
refresh_token=None,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -511,7 +510,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
# Push initial config to Supervisor and refresh issues state
|
||||
await asyncio.gather(
|
||||
update_hass_api(refresh_token),
|
||||
update_hass_api(),
|
||||
push_config(None),
|
||||
issues_coordinator.async_refresh(),
|
||||
)
|
||||
|
||||
@@ -9,33 +9,52 @@ from aiohttp import web
|
||||
|
||||
from homeassistant.components import frontend
|
||||
from homeassistant.components.http import HomeAssistantView, require_admin
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
|
||||
from .const import MAIN_COORDINATOR
|
||||
from .coordinator import HassioMainDataUpdateCoordinator
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def async_setup_addon_panel(hass: HomeAssistant) -> None:
|
||||
"""Add-on Ingress Panel setup."""
|
||||
hassio_addon_panel = HassIOAddonPanel(hass)
|
||||
hass.http.register_view(hassio_addon_panel)
|
||||
"""Register the add-on panel push API view."""
|
||||
hass.http.register_view(HassIOAddonPanel(hass))
|
||||
|
||||
# Handle existing panels on startup
|
||||
async def _async_panel_start_handler(event: Event) -> None:
|
||||
"""Process all existing panels on startup."""
|
||||
# Check if there are panels to register
|
||||
if not (panels := await hassio_addon_panel.get_panels()):
|
||||
return
|
||||
|
||||
# Register available panels
|
||||
for addon, data in panels.items():
|
||||
if not data.enable:
|
||||
continue
|
||||
_register_panel(hass, addon, data)
|
||||
@callback
|
||||
def async_setup_addon_panel_coordinator(
|
||||
hass: HomeAssistant, coordinator: HassioMainDataUpdateCoordinator
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Reconcile add-on panels registered with the frontend against coordinator data.
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_panel_start_handler)
|
||||
Registers the panels present after the coordinator's first refresh, then keeps
|
||||
the frontend in sync with coordinator.data.panels on every following update:
|
||||
periodic refreshes, a refresh triggered by a Supervisor restart, and a post/
|
||||
delete pushed by Supervisor and cached via coordinator.async_push_panel /
|
||||
coordinator.async_push_panel_removal.
|
||||
|
||||
Returns a function that unsubscribes from the coordinator.
|
||||
"""
|
||||
registered: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _async_reconcile_panels() -> None:
|
||||
"""Register or remove panels to match the coordinator's cached data."""
|
||||
panels = coordinator.data.panels
|
||||
wanted = {addon for addon, panel in panels.items() if panel.enable}
|
||||
|
||||
for addon in wanted - registered:
|
||||
_register_panel(hass, addon, panels[addon])
|
||||
for addon in registered - wanted:
|
||||
frontend.async_remove_panel(hass, addon, warn_if_unknown=False)
|
||||
|
||||
registered.clear()
|
||||
registered.update(wanted)
|
||||
|
||||
_async_reconcile_panels()
|
||||
return coordinator.async_add_listener(_async_reconcile_panels)
|
||||
|
||||
|
||||
class HassIOAddonPanel(HomeAssistantView):
|
||||
@@ -52,34 +71,46 @@ class HassIOAddonPanel(HomeAssistantView):
|
||||
@require_admin
|
||||
async def post(self, request: web.Request, addon: str) -> web.Response:
|
||||
"""Handle new add-on panel requests."""
|
||||
panels = await self.get_panels()
|
||||
# Supervisor calls this endpoint because an add-on's panel state just
|
||||
# changed, so fetch it fresh instead of relying on the coordinator's
|
||||
# cache, which may still hold the value from before this change.
|
||||
try:
|
||||
panels = await self.client.ingress.panels()
|
||||
except SupervisorError as err:
|
||||
_LOGGER.error("Can't read panel info: %s", err)
|
||||
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
# Panel exists for add-on slug
|
||||
if addon not in panels or not panels[addon].enable:
|
||||
_LOGGER.error("Panel is not enabled for %s", addon)
|
||||
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
# Register panel
|
||||
_register_panel(self.hass, addon, panels[addon])
|
||||
if (coordinator := self.hass.data.get(MAIN_COORDINATOR)) is not None:
|
||||
# Update the cache; the coordinator listener registers it with the frontend.
|
||||
coordinator.async_push_panel(addon, panels[addon])
|
||||
else:
|
||||
_register_panel(self.hass, addon, panels[addon])
|
||||
return web.Response()
|
||||
|
||||
@require_admin
|
||||
async def delete(self, request: web.Request, addon: str) -> web.Response:
|
||||
"""Handle remove add-on panel requests."""
|
||||
frontend.async_remove_panel(self.hass, addon)
|
||||
if (coordinator := self.hass.data.get(MAIN_COORDINATOR)) is not None:
|
||||
# Update the cache; the coordinator listener removes it from the frontend.
|
||||
coordinator.async_push_panel_removal(addon)
|
||||
else:
|
||||
frontend.async_remove_panel(self.hass, addon, warn_if_unknown=False)
|
||||
return web.Response()
|
||||
|
||||
async def get_panels(self) -> dict[str, IngressPanel]:
|
||||
"""Return panels add-on info data."""
|
||||
try:
|
||||
return await self.client.ingress.panels()
|
||||
except SupervisorError as err:
|
||||
_LOGGER.error("Can't read panel info: %s", err)
|
||||
return {}
|
||||
|
||||
def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel) -> None:
|
||||
"""Helper to register the panel.
|
||||
|
||||
def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel):
|
||||
"""Helper to register the panel."""
|
||||
Uses update=True so this is idempotent: a config entry reload can run this
|
||||
for a panel the frontend still has registered from before the reload, and
|
||||
the push API's early-startup fallback can register one before the
|
||||
coordinator's own reconciliation runs for the first time.
|
||||
"""
|
||||
frontend.async_register_built_in_panel(
|
||||
hass,
|
||||
"app",
|
||||
@@ -88,4 +119,5 @@ def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel):
|
||||
sidebar_icon=data.icon,
|
||||
require_admin=data.admin,
|
||||
config={"addon": addon},
|
||||
update=True,
|
||||
)
|
||||
|
||||
@@ -90,6 +90,7 @@ EVENT_SUPPORTED_CHANGED = "supported_changed"
|
||||
EVENT_ISSUE_CHANGED = "issue_changed"
|
||||
EVENT_ISSUE_REMOVED = "issue_removed"
|
||||
EVENT_JOB = "job"
|
||||
EVENT_STORE_RELOADED = "store_reloaded"
|
||||
|
||||
UPDATE_KEY_SUPERVISOR = "supervisor"
|
||||
STARTUP_COMPLETE = "complete"
|
||||
|
||||
@@ -16,6 +16,7 @@ from aiohasupervisor.models import (
|
||||
HomeAssistantInfo,
|
||||
HomeAssistantStats,
|
||||
HostInfo,
|
||||
IngressPanel,
|
||||
InstalledAddon,
|
||||
InstalledAddonComplete,
|
||||
Issue as SupervisorIssue,
|
||||
@@ -40,7 +41,7 @@ from homeassistant.core import (
|
||||
callback,
|
||||
is_callback_check_partial,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -83,6 +84,7 @@ from .const import (
|
||||
EVENT_ISSUE_CHANGED,
|
||||
EVENT_ISSUE_REMOVED,
|
||||
EVENT_JOB,
|
||||
EVENT_STORE_RELOADED,
|
||||
EVENT_SUPERVISOR_EVENT,
|
||||
EVENT_SUPERVISOR_UPDATE,
|
||||
EVENT_SUPPORTED_CHANGED,
|
||||
@@ -399,16 +401,28 @@ class SupervisorIssuesCoordinator(DataUpdateCoordinator[SupervisorIssuesData]):
|
||||
current_data: SupervisorIssuesData,
|
||||
) -> None:
|
||||
"""Create/delete issue repairs and notify subscribers based on issue deltas."""
|
||||
issue_registry = ir.async_get(self.hass)
|
||||
for issue in current_data.issues.values():
|
||||
previous_issue = previous_data.issues.get(issue.uuid)
|
||||
if previous_issue is not None and self._issue_equal(previous_issue, issue):
|
||||
continue
|
||||
|
||||
self._create_or_update_issue_repair(issue)
|
||||
self._process_issue_change(
|
||||
IssueSubscriptionEvent(event="changed", issue=issue)
|
||||
changed = previous_issue is None or not self._issue_equal(
|
||||
previous_issue, issue
|
||||
)
|
||||
|
||||
# Update the repair on changes, and re-create it if the registry
|
||||
# entry went missing: a finished repair flow deletes the entry
|
||||
# even when applying the suggestion failed in Supervisor and the
|
||||
# issue is unchanged.
|
||||
if changed or (
|
||||
issue.key in ISSUE_KEYS_FOR_REPAIRS
|
||||
and not issue_registry.async_get_issue(DOMAIN, issue.uuid.hex)
|
||||
):
|
||||
self._create_or_update_issue_repair(issue)
|
||||
|
||||
if changed:
|
||||
self._process_issue_change(
|
||||
IssueSubscriptionEvent(event="changed", issue=issue)
|
||||
)
|
||||
|
||||
for issue_uuid, issue in previous_data.issues.items():
|
||||
if issue_uuid not in current_data.issues:
|
||||
self._delete_issue_repair(issue)
|
||||
@@ -764,6 +778,7 @@ class HassioMainData:
|
||||
host: HostInfo
|
||||
mounts: dict[str, CIFSMountResponse | NFSMountResponse]
|
||||
os: OSInfo | None
|
||||
panels: dict[str, IngressPanel]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the data."""
|
||||
@@ -773,6 +788,7 @@ class HassioMainData:
|
||||
"host": self.host.to_dict(),
|
||||
"mounts": {name: mount.to_dict() for name, mount in self.mounts.items()},
|
||||
"os": self.os.to_dict() if self.os is not None else None,
|
||||
"panels": {slug: panel.to_dict() for slug, panel in self.panels.items()},
|
||||
}
|
||||
|
||||
|
||||
@@ -1284,6 +1300,23 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
|
||||
self.dev_reg = dev_reg
|
||||
self._addon_info_subscriptions: defaultdict[str, set[str]] = defaultdict(set)
|
||||
self.supervisor_client = get_supervisor_client(hass)
|
||||
self._dispatcher_disconnect = async_dispatcher_connect(
|
||||
hass, EVENT_SUPERVISOR_EVENT, self._supervisor_event
|
||||
)
|
||||
|
||||
@callback
|
||||
def _supervisor_event(self, event: dict[str, Any]) -> None:
|
||||
"""Refresh add-on data when Supervisor reloads the store."""
|
||||
if event.get(ATTR_WS_EVENT) != EVENT_STORE_RELOADED:
|
||||
return
|
||||
# Without listeners there are no add-on entities to keep in sync.
|
||||
# Scheduled polling is paused in that case as well, so don't let
|
||||
# store reload events trigger refreshes either.
|
||||
if not self._listeners:
|
||||
return
|
||||
self.config_entry.async_create_task(
|
||||
self.hass, self.async_refresh_after_store_reload()
|
||||
)
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> HassioAddonData:
|
||||
@@ -1347,9 +1380,7 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
|
||||
# Remove add-ons that are no longer installed from device registry
|
||||
supervisor_addon_devices = {
|
||||
list(device.identifiers)[0][1]
|
||||
for device in self.dev_reg.devices.get_devices_for_config_entry_id(
|
||||
self.entry_id
|
||||
)
|
||||
for device in dr.async_entries_for_config_entry(self.dev_reg, self.entry_id)
|
||||
if device.model == SupervisorEntityModel.ADDON
|
||||
}
|
||||
if stale_addons := supervisor_addon_devices - set(new_data.addons):
|
||||
@@ -1452,6 +1483,12 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
|
||||
addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {})
|
||||
addon_info_cache[slug] = info
|
||||
|
||||
@override
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shut down and clean up when config entry unloaded."""
|
||||
await super().async_shutdown()
|
||||
self._dispatcher_disconnect()
|
||||
|
||||
|
||||
class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
"""Class to retrieve Hass.io status."""
|
||||
@@ -1492,6 +1529,25 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
):
|
||||
self.config_entry.async_create_task(self.hass, self.async_request_refresh())
|
||||
|
||||
@callback
|
||||
def async_push_panel(self, addon: str, panel: IngressPanel) -> None:
|
||||
"""Apply a Supervisor panel push to cached data without touching refresh state."""
|
||||
self.data = replace(self.data, panels={**self.data.panels, addon: panel})
|
||||
self.async_update_listeners()
|
||||
|
||||
@callback
|
||||
def async_push_panel_removal(self, addon: str) -> None:
|
||||
"""Apply a Supervisor panel removal push to cached data."""
|
||||
if addon not in self.data.panels:
|
||||
return
|
||||
self.data = replace(
|
||||
self.data,
|
||||
panels={
|
||||
slug: panel for slug, panel in self.data.panels.items() if slug != addon
|
||||
},
|
||||
)
|
||||
self.async_update_listeners()
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> HassioMainData:
|
||||
"""Update data via library."""
|
||||
@@ -1501,7 +1557,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
try:
|
||||
# Cast is required here because asyncio.gather only has overloads to
|
||||
# maintain typing for 6 arguments. It falls back to list[<common parent>]
|
||||
# after that which is what mypy sees here since we have 7 API calls.
|
||||
# after that which is what mypy sees here since we have 8 API calls.
|
||||
(
|
||||
info,
|
||||
core_info,
|
||||
@@ -1510,6 +1566,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
host_info,
|
||||
store_info,
|
||||
network_info,
|
||||
panels_info,
|
||||
) = cast(
|
||||
tuple[
|
||||
RootInfo,
|
||||
@@ -1519,6 +1576,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
HostInfo,
|
||||
StoreInfo,
|
||||
NetworkInfo,
|
||||
dict[str, IngressPanel],
|
||||
],
|
||||
await asyncio.gather(
|
||||
client.info(),
|
||||
@@ -1528,6 +1586,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
client.host.info(),
|
||||
client.store.info(),
|
||||
client.network.info(),
|
||||
client.ingress.panels(),
|
||||
),
|
||||
)
|
||||
mounts_info = await client.mounts.info()
|
||||
@@ -1542,6 +1601,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
host=host_info,
|
||||
mounts={mount.name: mount for mount in mounts_info.mounts},
|
||||
os=os_info if self.is_hass_os else None,
|
||||
panels=panels_info,
|
||||
)
|
||||
|
||||
# Update hass.data for legacy accessor functions
|
||||
@@ -1569,9 +1629,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
|
||||
# Remove mounts that no longer exists from device registry
|
||||
supervisor_mount_devices = {
|
||||
device.name
|
||||
for device in self.dev_reg.devices.get_devices_for_config_entry_id(
|
||||
self.entry_id
|
||||
)
|
||||
for device in dr.async_entries_for_config_entry(self.dev_reg, self.entry_id)
|
||||
if device.model == SupervisorEntityModel.MOUNT
|
||||
}
|
||||
if stale_mounts := supervisor_mount_devices - set(new_data.mounts):
|
||||
|
||||
@@ -41,6 +41,7 @@ from .issues import Issue, Suggestion
|
||||
|
||||
SUGGESTION_CONFIRMATION_REQUIRED = {
|
||||
"addon_execute_remove",
|
||||
"mount_move_local_data",
|
||||
"system_adopt_data_disk",
|
||||
"system_execute_reboot",
|
||||
}
|
||||
|
||||
@@ -152,11 +152,15 @@
|
||||
},
|
||||
"step": {
|
||||
"fix_menu": {
|
||||
"description": "Could not connect to `{reference}`. Check host logs for errors from the mount service for more details.\n\nUse reload to try to connect again. If you need to update `{reference}`, go to [storage]({storage_url}).",
|
||||
"description": "Could not set up `{reference}`. This can happen when the storage device is unreachable, or when local data is blocking the mount location. Check host logs for errors from the mount service for more details.\n\nUse reload to try again. If you need to update `{reference}`, go to [storage]({storage_url}).",
|
||||
"menu_options": {
|
||||
"mount_execute_reload": "[%key:common::action::reload%]",
|
||||
"mount_execute_remove": "Remove"
|
||||
"mount_execute_remove": "Remove",
|
||||
"mount_move_local_data": "Move blocking local data away"
|
||||
}
|
||||
},
|
||||
"mount_move_local_data": {
|
||||
"description": "Select **Submit** to move the local data blocking `{reference}` to a `{reference}_local_recovery` folder and set up the mount again.\n\nNo files are deleted. For media and share mounts the recovery folder is created next to the mount, where you can review and remove it. For backup mounts it is placed in local backup storage."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@ from homeassistant.helpers.dispatcher import (
|
||||
|
||||
from .config_entry import async_get_hassio_entry, async_get_update_options
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_DATA,
|
||||
ATTR_ENDPOINT,
|
||||
ATTR_METHOD,
|
||||
@@ -59,10 +58,6 @@ WS_NO_ADMIN_ENDPOINTS = re.compile(
|
||||
r")$"
|
||||
)
|
||||
|
||||
# Endpoint that reloads the add-on store. Afterwards the add-on update
|
||||
# entities must be refreshed so they don't report stale update information.
|
||||
STORE_RELOAD_ENDPOINT = "/store/reload"
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
|
||||
|
||||
@@ -163,15 +158,6 @@ async def websocket_supervisor_api(
|
||||
# sensitive information and the frontend does not require it for ingress.
|
||||
if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command):
|
||||
data.pop("options", None)
|
||||
# Await so the frontend only sees the reload finish once the add-on
|
||||
# update entities reflect the reloaded store.
|
||||
if (
|
||||
command == STORE_RELOAD_ENDPOINT
|
||||
and msg[ATTR_METHOD] == "post"
|
||||
and (coordinator := hass.data.get(ADDONS_COORDINATOR))
|
||||
):
|
||||
await coordinator.async_refresh_after_store_reload()
|
||||
|
||||
connection.send_result(msg[WS_ID], data)
|
||||
|
||||
|
||||
|
||||
@@ -32,9 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
|
||||
|
||||
# Migrate non-string device identifiers.
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in device_registry.devices.get_devices_for_config_entry_id(
|
||||
entry.entry_id
|
||||
):
|
||||
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
|
||||
for ident in device.identifiers:
|
||||
if ident[0] != DOMAIN or isinstance(ident[1], str):
|
||||
continue
|
||||
|
||||
@@ -50,7 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool
|
||||
connections.add((dr.CONNECTION_NETWORK_MAC, mac))
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
hub_device = device_registry.async_get_or_create(
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, hub_data["device_id"])},
|
||||
connections=connections,
|
||||
@@ -59,11 +59,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool
|
||||
sw_version=hub_data["deviceData"]["version"],
|
||||
manufacturer=hub_data["deviceData"]["manufacturer"],
|
||||
)
|
||||
if hub_device.via_device_id is not None:
|
||||
# Older versions linked the hub's own diagnostic sensor to the hub itself;
|
||||
# clear the stale self-reference since async_get_or_create leaves
|
||||
# via_device_id untouched when it's not passed.
|
||||
device_registry.async_update_device(hub_device.id, via_device_id=None)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
entry,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any, override
|
||||
from typing import Any, Final, override
|
||||
|
||||
import jwt
|
||||
import voluptuous as vol
|
||||
@@ -13,6 +13,8 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
INPUT_IMAGES_SCOPE: Final = "images_scope"
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
@@ -23,12 +25,49 @@ class OAuth2FlowHandler(
|
||||
|
||||
MINOR_VERSION = 3
|
||||
|
||||
images_scope: bool | None = None
|
||||
|
||||
@property
|
||||
@override
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_authorize_data(self) -> dict[str, str]:
|
||||
return {
|
||||
"scope": (
|
||||
"Control Monitor Settings"
|
||||
f" IdentifyAppliance{' Images' if self.images_scope else ''}"
|
||||
),
|
||||
}
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow start."""
|
||||
return await self.async_step_scopes(user_input)
|
||||
|
||||
async def async_step_scopes(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Ask for the scopes to use."""
|
||||
if user_input is not None:
|
||||
self.images_scope = user_input[INPUT_IMAGES_SCOPE]
|
||||
if self.images_scope is not None:
|
||||
return await self.async_step_pick_implementation(None)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="scopes",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(INPUT_IMAGES_SCOPE): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"wrong_account": "Please ensure you reconfigure against the same account."
|
||||
},
|
||||
"create_entry": {
|
||||
@@ -38,6 +39,16 @@
|
||||
"reauth_confirm": {
|
||||
"description": "The Home Connect integration needs to re-authenticate your account",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"scopes": {
|
||||
"data": {
|
||||
"images_scope": "Images scope"
|
||||
},
|
||||
"data_description": {
|
||||
"images_scope": "Allows Home Assistant to access images from your Home Connect devices."
|
||||
},
|
||||
"description": "Select the optional scopes you want to enable for Home Connect authentication.",
|
||||
"title": "Scopes"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
"""Offer event listening automation rules."""
|
||||
|
||||
from collections.abc import ItemsView, Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_EVENT_DATA, CONF_PLATFORM, EVENT_STATE_REPORTED
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_ID,
|
||||
CONF_EVENT_DATA,
|
||||
CONF_PLATFORM,
|
||||
EVENT_STATE_REPORTED,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, template
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
template,
|
||||
)
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import yaml as yaml_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_EVENT_TYPE = "event_type"
|
||||
CONF_EVENT_CONTEXT = "context"
|
||||
@@ -39,6 +52,58 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
async def async_validate_trigger_config(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate trigger config.
|
||||
|
||||
Warn if the trigger filters event_data.device_id on a pre-migration composite device
|
||||
id - a device that was split into one device per config entry.
|
||||
A templated device id is a Template (not a plain string) and is left alone.
|
||||
"""
|
||||
validated_config: ConfigType = TRIGGER_SCHEMA(config)
|
||||
if (
|
||||
CONF_EVENT_DATA in validated_config
|
||||
and isinstance(
|
||||
device_id := validated_config[CONF_EVENT_DATA].get(CONF_DEVICE_ID), str
|
||||
)
|
||||
and (
|
||||
split_devices := dr.async_get(
|
||||
hass
|
||||
).async_get_devices_for_composite_device_id(device_id)
|
||||
)
|
||||
):
|
||||
_log_composite_device_id_warning(hass, config, device_id, split_devices)
|
||||
return validated_config
|
||||
|
||||
|
||||
@callback
|
||||
def _log_composite_device_id_warning(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
device_id: str,
|
||||
split_devices: list[dr.DeviceEntry],
|
||||
) -> None:
|
||||
"""Warn that an event trigger filters on a split (pre-migration) device id."""
|
||||
|
||||
device_summaries: list[str] = []
|
||||
for device in split_devices:
|
||||
entry = hass.config_entries.async_get_entry(device.config_entry_id)
|
||||
domain = entry.domain if entry else "unknown"
|
||||
name = device.name_by_user or device.name or device.id
|
||||
device_summaries.append(f"{name} ({device.id}) from the {domain} integration")
|
||||
|
||||
_LOGGER.warning(
|
||||
"Event trigger filters on device '%s', which was split into one device per "
|
||||
"integration and no longer exists, so the trigger can no longer fire. Update the "
|
||||
"automation, script or template entity to filter on one of these devices instead: "
|
||||
"%s.\nThe affected trigger is configured as:\n%s",
|
||||
device_id,
|
||||
", ".join(device_summaries),
|
||||
yaml_util.dump(config),
|
||||
)
|
||||
|
||||
|
||||
def _schema_value(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return vol.In(value)
|
||||
|
||||
@@ -1008,7 +1008,7 @@ class HomeKit:
|
||||
"""Purge bridges that exist from failed pairing or manual resets."""
|
||||
devices_to_purge = [
|
||||
entry.id
|
||||
for entry in dev_reg.devices.get_devices_for_config_entry_id(self._entry_id)
|
||||
for entry in dr.async_entries_for_config_entry(dev_reg, self._entry_id)
|
||||
if (
|
||||
identifier not in entry.identifiers # type: ignore[comparison-overlap]
|
||||
or connection not in entry.connections # type: ignore[unreachable]
|
||||
@@ -1071,9 +1071,17 @@ class HomeKit:
|
||||
dev_reg = dr.async_get(self.hass)
|
||||
valid_device_ids = []
|
||||
for device_id in self._devices:
|
||||
if dev_reg.async_get(device_id, include_child_devices=False):
|
||||
valid_device_ids.append(device_id)
|
||||
elif dev_reg.async_get(device_id, include_main_devices=False):
|
||||
device = dev_reg.async_get(device_id)
|
||||
if device is None:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"HomeKit %s cannot add device %s because it is missing from the"
|
||||
" device registry"
|
||||
),
|
||||
self._name,
|
||||
device_id,
|
||||
)
|
||||
elif isinstance(device, dr.ChildDeviceEntry):
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"HomeKit %s cannot add device %s because a child device cannot"
|
||||
@@ -1083,14 +1091,8 @@ class HomeKit:
|
||||
device_id,
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"HomeKit %s cannot add device %s because it is missing from the"
|
||||
" device registry"
|
||||
),
|
||||
self._name,
|
||||
device_id,
|
||||
)
|
||||
# A main or composite device is a valid HomeKit accessory
|
||||
valid_device_ids.append(device_id)
|
||||
for device_id, device_triggers in (
|
||||
await device_automation.async_get_device_automations(
|
||||
self.hass,
|
||||
@@ -1231,7 +1233,9 @@ class HomeKit:
|
||||
dev_reg_ent = dev_reg.async_get(ent_reg_ent.device_id)
|
||||
if isinstance(dev_reg_ent, dr.ChildDeviceEntry):
|
||||
# A child device has no hardware info of its own; use the parent's
|
||||
dev_reg_ent = dev_reg.devices.get(dev_reg_ent.parent_device_id)
|
||||
dev_reg_ent = dev_reg.async_get(
|
||||
dev_reg_ent.parent_device_id, include_child_devices=False
|
||||
)
|
||||
if dev_reg_ent is not None:
|
||||
self._fill_config_from_device_registry_entry(dev_reg_ent, ent_cfg)
|
||||
if ATTR_MANUFACTURER not in ent_cfg:
|
||||
|
||||
@@ -213,8 +213,9 @@ class HomematicipGenericEntity(Entity):
|
||||
if device_id := self.registry_entry.device_id:
|
||||
# Remove from device registry.
|
||||
device_registry = dr.async_get(self.hass)
|
||||
if device_id in device_registry.devices:
|
||||
# This will also remove associated entities from entity registry.
|
||||
# This will also remove associated entities from entity registry,
|
||||
# ignore an already removed device.
|
||||
with contextlib.suppress(KeyError):
|
||||
device_registry.async_remove_device(device_id)
|
||||
else: # noqa: PLR5501
|
||||
# Remove from entity registry.
|
||||
|
||||
@@ -19,7 +19,7 @@ from homewizard_energy.models import Device
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
@@ -60,7 +60,8 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_authorize()
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
f"{device_info.product_type}_{device_info.serial}"
|
||||
f"{device_info.product_type}_{device_info.serial}",
|
||||
raise_on_progress=False,
|
||||
)
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
return self.async_create_entry(
|
||||
@@ -110,7 +111,8 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
|
||||
await self.async_set_unique_id(
|
||||
f"{device_info.product_type}_{device_info.serial}"
|
||||
f"{device_info.product_type}_{device_info.serial}",
|
||||
raise_on_progress=self.source != SOURCE_USER,
|
||||
)
|
||||
self._abort_if_unique_id_configured(updates=data)
|
||||
return self.async_create_entry(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"device_not_supported": "This device is not supported",
|
||||
"invalid_discovery_parameters": "Invalid discovery parameters",
|
||||
"reauth_enable_api_successful": "Enabling API was successful",
|
||||
|
||||
@@ -5,7 +5,10 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import HotSpringConfigEntry, HotSpringDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.NUMBER]
|
||||
PLATFORMS = [
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HotSpringConfigEntry) -> bool:
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import TextSelector
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -38,6 +39,9 @@ class HotSpringConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Hot Spring."""
|
||||
|
||||
VERSION = 1
|
||||
discovered_host: str
|
||||
discovered_spa: Spa
|
||||
discovered_title: str
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
@@ -86,3 +90,37 @@ class HotSpringConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the Hot Spring spa."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
@override
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery."""
|
||||
self.discovered_host = discovery_info.host
|
||||
try:
|
||||
self.discovered_spa = await validate_input(
|
||||
self.hass, {CONF_HOST: discovery_info.host}
|
||||
)
|
||||
except HotSpringConnectionError, HotSpringError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(self.discovered_spa.info.mac_address)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
|
||||
|
||||
self.discovered_title = self.discovered_spa.info.hostname or "Hot Spring Spa"
|
||||
self.context["title_placeholders"] = {"name": self.discovered_title}
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="zeroconf_confirm",
|
||||
description_placeholders={"name": self.discovered_title},
|
||||
)
|
||||
|
||||
async def async_step_zeroconf_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by zeroconf."""
|
||||
return self.async_create_entry(
|
||||
title=self.discovered_title,
|
||||
data={CONF_HOST: self.discovered_host},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Diagnostics support for Hot Spring."""
|
||||
|
||||
from dataclasses import asdict
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import REDACTED, async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import HotSpringConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_HOST,
|
||||
}
|
||||
|
||||
|
||||
def _redact_mac(value: str, patterns: list[str]) -> str:
|
||||
"""Redact MAC address patterns from a string."""
|
||||
for pattern in patterns:
|
||||
value = re.sub(re.escape(pattern), REDACTED, value, flags=re.IGNORECASE)
|
||||
return value
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: HotSpringConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
spa = coordinator.data
|
||||
|
||||
info = asdict(spa.info)
|
||||
if mac_address := spa.info.mac_address:
|
||||
clean_mac = mac_address.replace(":", "")
|
||||
patterns = [mac_address, clean_mac, clean_mac[-6:]]
|
||||
info["root_topic"] = _redact_mac(info["root_topic"], patterns)
|
||||
info["hostname"] = _redact_mac(info["hostname"], patterns)
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": {
|
||||
"info": info,
|
||||
"heater": asdict(spa.heater),
|
||||
"jets": [asdict(jet) for jet in spa.jets],
|
||||
"blower": asdict(spa.blower),
|
||||
"light_zones": [asdict(zone) for zone in spa.light_zones],
|
||||
"logo_light": asdict(spa.logo_light),
|
||||
"clean_cycle": asdict(spa.clean_cycle),
|
||||
"spa_lock": asdict(spa.spa_lock),
|
||||
"water_care": asdict(spa.water_care),
|
||||
"freshwater_iq": asdict(spa.freshwater_iq),
|
||||
"energy_savings": [asdict(schedule) for schedule in spa.energy_savings],
|
||||
"versions": asdict(spa.versions),
|
||||
"connection_status": asdict(spa.connection_status),
|
||||
"diagnostics": asdict(spa.diagnostics),
|
||||
"test_metrics": asdict(spa.test_metrics),
|
||||
},
|
||||
}
|
||||
@@ -8,5 +8,11 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["hotspring"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-hotspring==1.3.0"]
|
||||
"requirements": ["python-hotspring==1.3.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "watkins_spa*",
|
||||
"type": "_ws._tcp.local."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Support for Hot Spring sensors."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import override
|
||||
|
||||
from hotspring import Spa
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .coordinator import HotSpringConfigEntry, HotSpringDataUpdateCoordinator
|
||||
from .entity import HotSpringEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HotSpringSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Hot Spring sensor entity."""
|
||||
|
||||
exists_fn: Callable[[Spa], bool] = lambda _: True
|
||||
value_fn: Callable[[Spa], StateType]
|
||||
|
||||
|
||||
SENSORS: tuple[HotSpringSensorEntityDescription, ...] = (
|
||||
HotSpringSensorEntityDescription(
|
||||
key="current_temperature",
|
||||
translation_key="current_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
value_fn=lambda spa: spa.heater.current_temperature,
|
||||
),
|
||||
HotSpringSensorEntityDescription(
|
||||
key="water_care_120_day_timer",
|
||||
translation_key="water_care_120_day_timer",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.DAYS,
|
||||
value_fn=lambda spa: spa.water_care.one_twenty_day_timer,
|
||||
exists_fn=lambda spa: spa.water_care.cartridge_installed,
|
||||
),
|
||||
HotSpringSensorEntityDescription(
|
||||
key="water_care_salt_value",
|
||||
translation_key="water_care_salt_value",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda spa: spa.water_care.salt_value,
|
||||
exists_fn=lambda spa: spa.water_care.cartridge_installed,
|
||||
),
|
||||
HotSpringSensorEntityDescription(
|
||||
key="water_care_10_day_timer",
|
||||
translation_key="water_care_10_day_timer",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.DAYS,
|
||||
value_fn=lambda spa: spa.water_care.ten_day_timer,
|
||||
exists_fn=lambda spa: spa.water_care.cartridge_installed,
|
||||
),
|
||||
HotSpringSensorEntityDescription(
|
||||
key="control_box_version",
|
||||
translation_key="control_box_version",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda spa: spa.versions.control_box,
|
||||
exists_fn=lambda spa: bool(spa.versions.control_box),
|
||||
),
|
||||
HotSpringSensorEntityDescription(
|
||||
key="wifi_dongle_version",
|
||||
translation_key="wifi_dongle_version",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda spa: spa.versions.wifi_dongle,
|
||||
exists_fn=lambda spa: bool(spa.versions.wifi_dongle),
|
||||
),
|
||||
HotSpringSensorEntityDescription(
|
||||
key="fwss_version",
|
||||
translation_key="fwss_version",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda spa: spa.versions.fwss,
|
||||
exists_fn=lambda spa: bool(spa.versions.fwss),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HotSpringConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Hot Spring sensor entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
HotSpringSensorEntity(coordinator, description)
|
||||
for description in SENSORS
|
||||
if description.exists_fn(coordinator.data)
|
||||
)
|
||||
|
||||
|
||||
class HotSpringSensorEntity(HotSpringEntity, SensorEntity):
|
||||
"""Defines a Hot Spring sensor entity."""
|
||||
|
||||
entity_description: HotSpringSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HotSpringDataUpdateCoordinator,
|
||||
description: HotSpringSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor entity."""
|
||||
super().__init__(coordinator, description.key)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
@override
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
@@ -18,6 +18,10 @@
|
||||
"host": "Hostname or IP address of your Hot Spring Home Network Adapter (HNA)."
|
||||
},
|
||||
"description": "Set up your Hot Spring Home Network Adapter (HNA) to integrate with Home Assistant."
|
||||
},
|
||||
"zeroconf_confirm": {
|
||||
"description": "Do you want to add the Hot Spring spa named `{name}` to Home Assistant?",
|
||||
"title": "Discovered Hot Spring spa"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -26,6 +30,29 @@
|
||||
"target_temperature": {
|
||||
"name": "Target temperature"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"control_box_version": {
|
||||
"name": "Control box version"
|
||||
},
|
||||
"current_temperature": {
|
||||
"name": "Current temperature"
|
||||
},
|
||||
"fwss_version": {
|
||||
"name": "FreshWater Salt System version"
|
||||
},
|
||||
"water_care_10_day_timer": {
|
||||
"name": "Salt 10-day check timer"
|
||||
},
|
||||
"water_care_120_day_timer": {
|
||||
"name": "Salt cartridge age"
|
||||
},
|
||||
"water_care_salt_value": {
|
||||
"name": "Salt value"
|
||||
},
|
||||
"wifi_dongle_version": {
|
||||
"name": "Wi-Fi dongle version"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -46,7 +46,7 @@ IP_BANS_FILE: Final = "ip_bans.yaml"
|
||||
ATTR_BANNED_AT: Final = "banned_at"
|
||||
|
||||
SCHEMA_IP_BAN_ENTRY: Final = vol.Schema(
|
||||
{vol.Optional("banned_at"): vol.Any(None, cv.datetime)}
|
||||
{vol.Optional(ATTR_BANNED_AT, default=None): vol.Any(None, cv.datetime)}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["bleak", "HueBLE"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["HueBLE==2.2.2"]
|
||||
"requirements": ["HueBLE==2.2.3"]
|
||||
}
|
||||
|
||||
@@ -218,8 +218,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
|
||||
registered_devices: set[str] = {
|
||||
str(mower_id)
|
||||
for device in device_registry.devices.get_devices_for_config_entry_id(
|
||||
self.config_entry.entry_id
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, self.config_entry.entry_id
|
||||
)
|
||||
for domain, mower_id in device.identifiers
|
||||
if domain == DOMAIN
|
||||
|
||||
@@ -70,9 +70,6 @@ async def async_setup_entry(
|
||||
manufacturer=MANUFACTURER,
|
||||
model=controller.hardware.model.description,
|
||||
name=controller.name,
|
||||
# Explicitly clear any via_device_id: older versions linked the
|
||||
# controller device to itself via its rain sensor entity.
|
||||
via_device_id=None,
|
||||
)
|
||||
|
||||
# Register the controllers known at setup before the platforms construct
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceRegistry
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
@@ -112,7 +112,7 @@ class IBeaconCoordinator:
|
||||
"""Set up the iBeacon Coordinator."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry, registry: DeviceRegistry
|
||||
self, hass: HomeAssistant, entry: ConfigEntry, registry: dr.DeviceRegistry
|
||||
) -> None:
|
||||
"""Initialize the Coordinator."""
|
||||
self.hass = hass
|
||||
@@ -508,8 +508,8 @@ class IBeaconCoordinator:
|
||||
@callback
|
||||
def _async_restore_from_registry(self) -> None:
|
||||
"""Restore the state of the Coordinator from the device registry."""
|
||||
for device in self._dev_reg.devices.get_devices_for_config_entry_id(
|
||||
self._entry.entry_id
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
self._dev_reg, self._entry.entry_id
|
||||
):
|
||||
if not (identifier := next(iter(device.identifiers), None)):
|
||||
continue
|
||||
|
||||
@@ -47,9 +47,7 @@ def async_cleanup_stale_devices(
|
||||
"""Cleanup stale heater devices and climates."""
|
||||
heater_serial_numbers = {heater.serial_no for heater in data.heaters}
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = device_registry.devices.get_devices_for_config_entry_id(
|
||||
entry.entry_id
|
||||
)
|
||||
device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
||||
stale_heater_serial_numbers: list[str] = [
|
||||
device_entry.serial_number
|
||||
for device_entry in device_entries
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyiqvia"],
|
||||
"requirements": ["numpy==2.3.2", "pyiqvia==2022.04.0"]
|
||||
"requirements": ["numpy==2.5.2", "pyiqvia==2022.04.0"]
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ def _categorize_nodes(
|
||||
isy_data.nodes[ISY_GROUP_PLATFORM].append(node)
|
||||
continue
|
||||
|
||||
if node.protocol == PROTO_INSTEON:
|
||||
if node.protocol in (PROTO_INSTEON, PROTO_ZWAVE):
|
||||
for control in node.aux_properties:
|
||||
if control in SKIP_AUX_PROPS:
|
||||
continue
|
||||
|
||||
@@ -15,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .coordinator import JVCConfigEntry
|
||||
from .entity import JvcProjectorEntity
|
||||
|
||||
POWER_SLEEP = 1
|
||||
|
||||
COMMANDS: list[str] = [
|
||||
cmd.Remote.MENU,
|
||||
cmd.Remote.UP,
|
||||
@@ -92,14 +94,14 @@ class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity):
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
await self.device.set(cmd.Power, cmd.Power.ON)
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(POWER_SLEEP)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@override
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
await self.device.set(cmd.Power, cmd.Power.OFF)
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(POWER_SLEEP)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@override
|
||||
|
||||
@@ -45,7 +45,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
# supports core bluetooth discovery
|
||||
if config_entry.version == 1:
|
||||
dev_reg = dr.async_get(hass)
|
||||
devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
|
||||
devices = dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)
|
||||
|
||||
if len(devices) == 0:
|
||||
_LOGGER.error("Unable to migrate; No devices registered")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user