Merge branch 'dev' into frenck/probatio-validation-engine

This commit is contained in:
Franck Nijhof
2026-08-22 14:03:57 +02:00
committed by GitHub
539 changed files with 29299 additions and 3864 deletions
+39 -15
View File
@@ -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
+2 -2
View File
@@ -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}"
},
+2
View File
@@ -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
+3 -2
View File
@@ -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
+166 -12
View File
@@ -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:
+5 -6
View File
@@ -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
+8 -3
View File
@@ -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),
+2 -5
View File
@@ -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:
+7
View File
@@ -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",
+1 -1
View File
@@ -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 (
+6 -2
View File
@@ -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:
+2 -2
View File
@@ -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
)
+2 -2
View File
@@ -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."]
}
+3 -5
View File
@@ -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 -2
View File
@@ -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
}
+33 -11
View File
@@ -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 -6
View File
@@ -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)
+17 -7
View File
@@ -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)
+14 -10
View File
@@ -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
)
)
)
+20 -7
View File
@@ -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:
+35 -20
View File
@@ -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
+11 -4
View File
@@ -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))
+2 -2
View File
@@ -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
+5 -2
View File
@@ -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:
+8 -5
View File
@@ -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"]
}
+9 -10
View File
@@ -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(),
)
+62 -30
View File
@@ -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,
)
+1
View File
@@ -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"
+72 -14
View File
@@ -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",
}
+6 -2
View File
@@ -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)
+1 -3
View File
@@ -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
+1 -6
View File
@@ -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)
+17 -13
View File
@@ -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": {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -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