mirror of
https://github.com/home-assistant/core.git
synced 2026-06-03 18:03:43 +02:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b06228a5a | |||
| 06b2ec22f0 | |||
| 7950998083 | |||
| 86999063d7 | |||
| 9843fdad2c | |||
| e53914a0ef | |||
| f7afe22318 | |||
| acfecd7f5c | |||
| 56057a11e6 | |||
| 0d079c57e4 | |||
| 3ad3e1fafb | |||
| 0677ed824f | |||
| 4b9945e012 | |||
| 9fa0132b1c | |||
| 10a25368a0 | |||
| fbb68c26b6 | |||
| 25875de414 | |||
| 22ace88b2c | |||
| a47105d314 | |||
| b50bfda00c | |||
| 0d37319ba9 | |||
| 24a5c75cf2 | |||
| dd43b1135d | |||
| de0a202c4e | |||
| d550d1da90 | |||
| ce8875ae8c | |||
| 3364096b2b | |||
| c2b75b9634 | |||
| ae278d3c80 | |||
| 25f9cd9ab8 | |||
| 796d82d6ed | |||
| 4b517fb164 | |||
| 2d74091a36 | |||
| 504e22ee3e | |||
| c95a39c26e | |||
| 8ec3eac705 | |||
| 589d2637c9 | |||
| 26cf728165 | |||
| b61559bdbb | |||
| 57259132d9 | |||
| 2776e966ff | |||
| 5f9872886d | |||
| f728a1bf09 | |||
| df65132268 | |||
| c13822b776 | |||
| c6d696db0c | |||
| 114c9bbafa | |||
| 323ce99fda | |||
| 7a7ef85db2 | |||
| 7ab402618d | |||
| aa87295a1e | |||
| 3bd979e976 | |||
| 9dddf76548 | |||
| 1828579f03 | |||
| 47bca8d8c2 | |||
| 6f3fb5c7bd | |||
| d9b4b5b3d0 | |||
| 342b364af6 | |||
| 951cd71741 | |||
| e86a54f81c | |||
| ba8b33e1a9 | |||
| b6c40ba3fc | |||
| f2f29c07c7 | |||
| 50a3ab115d | |||
| c204054847 | |||
| 28d6eab2dd | |||
| 6b1ee57bd5 | |||
| 7247f95b05 | |||
| cdeafdfd42 | |||
| 9d60fce72e | |||
| 2e4c6c4370 | |||
| b7e36e297b | |||
| 7e178efe63 | |||
| 38f25c4b41 | |||
| 2c2e70a11c | |||
| 190350aec3 | |||
| a87083b6c1 | |||
| d5be54fd40 | |||
| 46f2ad9eb2 | |||
| add75622d6 | |||
| 2f334d657d | |||
| fd69d384be | |||
| fce17c8e6f |
@@ -6,6 +6,7 @@
|
||||
"lg_netcast",
|
||||
"lg_soundbar",
|
||||
"lg_thinq",
|
||||
"lg_tv_rs232",
|
||||
"webostv"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -72,8 +72,7 @@ async def _resolve_attachments(
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=attachment.get("media_content_type")
|
||||
or image_data.content_type,
|
||||
mime_type=image_data.content_type,
|
||||
path=temp_filename,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Alexa Devices integration."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
from homeassistant.const import CONF_COUNTRY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
|
||||
@@ -46,21 +43,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
async def _on_http2_reauth_required() -> None:
|
||||
entry.async_start_reauth(hass)
|
||||
|
||||
async def _cancel_http2() -> None:
|
||||
http2_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await http2_task
|
||||
|
||||
alexa_httpx_client = httpx_client.get_async_client(
|
||||
hass,
|
||||
alpn_protocols=SSL_ALPN_HTTP11_HTTP2,
|
||||
)
|
||||
|
||||
http2_task = await coordinator.api.start_http2_processing(
|
||||
alexa_httpx_client, on_reauth_required=_on_http2_reauth_required
|
||||
await coordinator.api.start_http2_processing(
|
||||
alexa_httpx_client,
|
||||
on_reauth_required=_on_http2_reauth_required,
|
||||
)
|
||||
|
||||
entry.async_on_unload(_cancel_http2)
|
||||
entry.async_on_unload(coordinator.api.stop_http2_processing)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
|
||||
@@ -39,11 +39,8 @@ async def async_setup_entry(
|
||||
class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
|
||||
"""Button entity for Alexa routine."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: AmazonDevicesCoordinator, routine: str) -> None:
|
||||
"""Initialize the routine button entity."""
|
||||
self._coordinator = coordinator
|
||||
self._routine = routine
|
||||
super().__init__(
|
||||
coordinator,
|
||||
@@ -52,4 +49,4 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle button press action."""
|
||||
await self._coordinator.api.call_routine(self._routine)
|
||||
await self.coordinator.api.call_routine(self._routine)
|
||||
|
||||
@@ -53,7 +53,7 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity):
|
||||
|
||||
_attr_event_types = [EVENT_TYPE]
|
||||
coordinator: AmazonDevicesCoordinator
|
||||
_last_seen_timestamp: int | None = None
|
||||
_last_seen_timestamp: int = 0 # January 1, 1970 at 12:00:00 AM
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
@@ -71,7 +71,8 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity):
|
||||
)
|
||||
return
|
||||
|
||||
if vocal_record.timestamp == self._last_seen_timestamp:
|
||||
if vocal_record.timestamp <= self._last_seen_timestamp:
|
||||
# Discard old events that have already been processed
|
||||
return
|
||||
|
||||
self._last_seen_timestamp = vocal_record.timestamp
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.8.0"]
|
||||
"requirements": ["aioamazondevices==13.8.2"]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -49,6 +53,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
@@ -57,18 +62,39 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
snapshots_url: str | None = None
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
snapshots_url = None
|
||||
|
||||
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Analytics from a config entry."""
|
||||
snapshots_url = hass.data[_DATA_SNAPSHOTS_URL]
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
try:
|
||||
await analytics.load()
|
||||
except HassioNotReadyError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_ready",
|
||||
) from err
|
||||
|
||||
started = False
|
||||
|
||||
@@ -80,8 +106,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
async def start_schedule(hass: HomeAssistant) -> None:
|
||||
"""Start the send schedule once Home Assistant has started."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
@@ -89,12 +115,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
async_at_started(hass, start_schedule)
|
||||
|
||||
hass.data[DATA_COMPONENT] = analytics
|
||||
return True
|
||||
@@ -109,7 +130,9 @@ def websocket_analytics(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return analytics preferences."""
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
|
||||
@@ -130,8 +153,10 @@ async def websocket_analytics_preferences(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
preferences = msg[ATTR_PREFERENCES]
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.async_schedule()
|
||||
|
||||
@@ -299,12 +299,8 @@ class Analytics:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
if self.supervisor and not self.onboarded:
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable
|
||||
# during setup of the Supervisor integration. That will fail setup
|
||||
# of this integration. However there is no better option at this time
|
||||
# since we need to get the diagnostic setting from Supervisor to correctly
|
||||
# setup this integration and we can't raise ConfigEntryNotReady to
|
||||
# trigger a retry from async_setup.
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable.
|
||||
# The caller is responsible for handling this and triggering a retry.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
@@ -349,10 +345,10 @@ class Analytics:
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
# get_supervisor_info was called during setup so we can't get here
|
||||
# if it raised. The others may raise HassioNotReadyError if only some
|
||||
# data was successfully fetched from Supervisor
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
# Try to pull Supervisor information, but don't fail if some or all
|
||||
# of it is unavailable due to setup failures in the hassio integration.
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
operating_system_info = hassio.get_os_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Config flow for Analytics integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Analytics."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return self.async_create_entry(title="Analytics", data={})
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
@@ -14,5 +15,6 @@
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal"
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"supervisor_not_ready": {
|
||||
"message": "Supervisor was not ready during setup, will retry"
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
|
||||
@@ -38,11 +38,13 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import AppleTvConfigEntry, AppleTVManager
|
||||
from .browse_media import build_app_list
|
||||
from .const import DOMAIN
|
||||
from .entity import AppleTVEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -126,7 +128,6 @@ class AppleTvMediaPlayer(
|
||||
@callback
|
||||
def async_device_connected(self, atv: AppleTV) -> None:
|
||||
"""Handle when connection is made to device."""
|
||||
# NB: Do not use _is_feature_available here as it only works when playing
|
||||
if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
|
||||
atv.push_updater.listener = self
|
||||
atv.push_updater.start()
|
||||
@@ -352,21 +353,41 @@ class AppleTvMediaPlayer(
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
media_type = MediaType.MUSIC
|
||||
|
||||
if self._is_feature_available(FeatureName.StreamFile) and (
|
||||
use_stream_file = self._is_feature_available(FeatureName.StreamFile) and (
|
||||
media_type == MediaType.MUSIC or await is_streamable(media_id)
|
||||
):
|
||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||
await self.atv.stream.stream_file(media_id)
|
||||
elif self._is_feature_available(FeatureName.PlayUrl) and (
|
||||
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
|
||||
):
|
||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||
await self.atv.stream.play_url(media_id)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Media streaming is not possible with current configuration for %s",
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
if use_stream_file:
|
||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||
await self.atv.stream.stream_file(media_id)
|
||||
elif self._is_feature_available(FeatureName.PlayUrl) and (
|
||||
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
|
||||
):
|
||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||
await self.atv.stream.play_url(media_id)
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="streaming_not_supported",
|
||||
)
|
||||
except exceptions.NotSupportedError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="streaming_not_supported",
|
||||
) from ex
|
||||
except (
|
||||
exceptions.BlockedStateError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.InvalidStateError,
|
||||
exceptions.OperationTimeoutError,
|
||||
exceptions.PlaybackError,
|
||||
exceptions.ProtocolError,
|
||||
) as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="stream_failed",
|
||||
) from ex
|
||||
|
||||
@property
|
||||
def media_image_hash(self) -> str | None:
|
||||
@@ -460,7 +481,7 @@ class AppleTvMediaPlayer(
|
||||
|
||||
def _is_feature_available(self, feature: FeatureName) -> bool:
|
||||
"""Return if a feature is available."""
|
||||
if self.atv and self._playing:
|
||||
if self.atv:
|
||||
return self.atv.features.in_state(FeatureState.Available, feature)
|
||||
return False
|
||||
|
||||
|
||||
@@ -81,6 +81,12 @@
|
||||
},
|
||||
"not_connected": {
|
||||
"message": "Apple TV is not connected"
|
||||
},
|
||||
"stream_failed": {
|
||||
"message": "Failed to stream media to the Apple TV"
|
||||
},
|
||||
"streaming_not_supported": {
|
||||
"message": "Streaming the requested media is not supported"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
|
||||
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import avea
|
||||
from bleak.exc import BleakError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
@@ -66,6 +67,15 @@ def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
|
||||
return AVEA_SERVICE_UUID in discovery_info.service_uuids
|
||||
|
||||
|
||||
def _discovery_label(discovery_info: BluetoothServiceInfoBleak) -> str:
|
||||
"""Return a label for a discovered Avea bulb."""
|
||||
if (
|
||||
name := _normalize_name(discovery_info.name)
|
||||
) and name != discovery_info.address:
|
||||
return f"{name} ({discovery_info.address})"
|
||||
return discovery_info.address
|
||||
|
||||
|
||||
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Avea."""
|
||||
|
||||
@@ -150,6 +160,7 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if discovery := self._discovery_info:
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
else:
|
||||
await bluetooth.async_request_active_scan(self.hass)
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in async_discovered_service_info(self.hass):
|
||||
if (
|
||||
@@ -165,11 +176,10 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if self._discovery_info:
|
||||
disc = self._discovery_info
|
||||
label = f"{disc.name or disc.address} ({disc.address})"
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS, default=disc.address): vol.In(
|
||||
{disc.address: label}
|
||||
{disc.address: _discovery_label(disc)}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -178,10 +188,7 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(
|
||||
{
|
||||
service_info.address: (
|
||||
f"{service_info.name or service_info.address}"
|
||||
f" ({service_info.address})"
|
||||
)
|
||||
service_info.address: _discovery_label(service_info)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .models import AgentBackup, BackupNotFound
|
||||
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
|
||||
from .util import read_backup, suggested_filename
|
||||
|
||||
|
||||
@@ -54,7 +54,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
try:
|
||||
backup = read_backup(backup_path)
|
||||
backups[backup.backup_id] = (backup, backup_path)
|
||||
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
|
||||
except (
|
||||
OSError,
|
||||
TarError,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
InvalidBackupFilename,
|
||||
) as err:
|
||||
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
|
||||
return backups
|
||||
|
||||
@@ -122,7 +128,14 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
|
||||
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||
"""Return the local path to a new backup."""
|
||||
return self._backup_dir / suggested_filename(backup)
|
||||
candidate = self._backup_dir / suggested_filename(backup)
|
||||
# suggested_filename does not strip separators; refuse paths that would
|
||||
# land outside the backup directory.
|
||||
if candidate.parent != self._backup_dir:
|
||||
raise InvalidBackupFilename(
|
||||
f"Refusing to write outside {self._backup_dir}: {candidate}"
|
||||
)
|
||||
return candidate
|
||||
|
||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
||||
"""Delete a backup file."""
|
||||
|
||||
@@ -1978,7 +1978,13 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
|
||||
try:
|
||||
backup = await async_add_executor_job(read_backup, temp_file)
|
||||
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err:
|
||||
except (
|
||||
OSError,
|
||||
tarfile.TarError,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
InvalidBackupFilename,
|
||||
) as err:
|
||||
LOGGER.warning("Unable to parse backup %s: %s", temp_file, err)
|
||||
raise
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import copy
|
||||
from dataclasses import dataclass, replace
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path, PurePath
|
||||
from pathlib import Path, PurePath, PureWindowsPath
|
||||
from queue import SimpleQueue
|
||||
import tarfile
|
||||
import threading
|
||||
@@ -34,7 +34,7 @@ from homeassistant.util.async_iterator import (
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION
|
||||
from .models import AddonInfo, AgentBackup, Folder
|
||||
from .models import AddonInfo, AgentBackup, Folder, InvalidBackupFilename
|
||||
|
||||
|
||||
class DecryptError(HomeAssistantError):
|
||||
@@ -109,6 +109,13 @@ def read_backup(backup_path: Path) -> AgentBackup:
|
||||
extra_metadata = cast(dict[str, bool | str], data.get("extra", {}))
|
||||
date = extra_metadata.get("supervisor.backup_request_date", data["date"])
|
||||
|
||||
name = cast(str, data["name"])
|
||||
# The name is used to derive the on-disk filename via suggested_filename;
|
||||
# reject anything that could escape the backup directory.
|
||||
safe_name = PureWindowsPath(name).name
|
||||
if safe_name != name or name in ("", ".", ".."):
|
||||
raise InvalidBackupFilename(f"Invalid backup name: {name!r}")
|
||||
|
||||
return AgentBackup(
|
||||
addons=addons,
|
||||
backup_id=cast(str, data["slug"]),
|
||||
@@ -118,7 +125,7 @@ def read_backup(backup_path: Path) -> AgentBackup:
|
||||
folders=folders,
|
||||
homeassistant_included=homeassistant_included,
|
||||
homeassistant_version=homeassistant_version,
|
||||
name=cast(str, data["name"]),
|
||||
name=name,
|
||||
protected=cast(bool, data.get("protected", False)),
|
||||
size=backup_path.stat().st_size,
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
|
||||
from habluetooth import (
|
||||
BaseHaRemoteScanner,
|
||||
BaseHaScanner,
|
||||
BluetoothReachabilityIntent,
|
||||
BluetoothScannerDevice,
|
||||
BluetoothScanningMode,
|
||||
HaBluetoothConnector,
|
||||
@@ -55,6 +56,7 @@ from . import passive_update_processor, websocket_api
|
||||
from .api import (
|
||||
_get_manager,
|
||||
async_address_present,
|
||||
async_address_reachability_diagnostics,
|
||||
async_ble_device_from_address,
|
||||
async_clear_address_from_match_history,
|
||||
async_clear_advertisement_history,
|
||||
@@ -108,12 +110,14 @@ __all__ = [
|
||||
"BluetoothCallback",
|
||||
"BluetoothCallbackMatcher",
|
||||
"BluetoothChange",
|
||||
"BluetoothReachabilityIntent",
|
||||
"BluetoothScannerDevice",
|
||||
"BluetoothScanningMode",
|
||||
"BluetoothServiceInfo",
|
||||
"BluetoothServiceInfoBleak",
|
||||
"HaBluetoothConnector",
|
||||
"async_address_present",
|
||||
"async_address_reachability_diagnostics",
|
||||
"async_ble_device_from_address",
|
||||
"async_clear_address_from_match_history",
|
||||
"async_clear_advertisement_history",
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, cast
|
||||
from bleak import BleakScanner
|
||||
from habluetooth import (
|
||||
BaseHaScanner,
|
||||
BluetoothReachabilityIntent,
|
||||
BluetoothScannerDevice,
|
||||
BluetoothScanningMode,
|
||||
HaBleakScannerWrapper,
|
||||
@@ -108,6 +109,14 @@ def async_ble_device_from_address(
|
||||
return _get_manager(hass).async_ble_device_from_address(address, connectable)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_address_reachability_diagnostics(
|
||||
hass: HomeAssistant, address: str, intent: BluetoothReachabilityIntent
|
||||
) -> str:
|
||||
"""Return a human readable explanation of why an address may be unreachable."""
|
||||
return _get_manager(hass).async_address_reachability_diagnostics(address, intent)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_scanner_devices_by_address(
|
||||
hass: HomeAssistant, address: str, connectable: bool = True
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.3.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.15",
|
||||
"habluetooth==6.7.9"
|
||||
"dbus-fast==5.0.16",
|
||||
"habluetooth==6.8.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Sony Bravia TV",
|
||||
"codeowners": ["@bieniu", "@Drafteed"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/braviatv",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -92,7 +92,7 @@ class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity):
|
||||
"""Representation of a Broadlink RF transmitter."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_translation_key = "rf_transmitter"
|
||||
|
||||
def __init__(self, device: BroadlinkDevice) -> None:
|
||||
"""Initialize the entity."""
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
"name": "IR emitter"
|
||||
}
|
||||
},
|
||||
"radio_frequency": {
|
||||
"rf_transmitter": {
|
||||
"name": "RF transmitter"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"day_of_week": {
|
||||
"name": "Day of week",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
|
||||
import caldav
|
||||
from caldav.lib.error import DAVError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -26,7 +27,7 @@ async def async_get_calendars(
|
||||
for calendar in client.principal().calendars():
|
||||
try:
|
||||
supported_components = calendar.get_supported_components()
|
||||
except KeyError:
|
||||
except KeyError, DAVError:
|
||||
needs_warning.append((str(calendar.url), calendar.name, component))
|
||||
|
||||
if component in ASSUMED_COMPONENTS:
|
||||
|
||||
@@ -66,5 +66,10 @@ async def get_cert_expiry_timestamp(
|
||||
except ssl.SSLError as err:
|
||||
raise ValidationFailure(err.args[0]) from err
|
||||
|
||||
if not cert or "notAfter" not in cert:
|
||||
raise ValidationFailure(
|
||||
f"No certificate expiration found for: {hostname}:{port}"
|
||||
)
|
||||
|
||||
ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"])
|
||||
return dt_util.utc_from_timestamp(ts_seconds)
|
||||
|
||||
@@ -20,6 +20,8 @@ from denonavr.const import (
|
||||
from denonavr.exceptions import (
|
||||
AvrCommandError,
|
||||
AvrForbiddenError,
|
||||
AvrIncompleteResponseError,
|
||||
AvrInvalidResponseError,
|
||||
AvrNetworkError,
|
||||
AvrProcessingError,
|
||||
AvrTimoutError,
|
||||
@@ -191,6 +193,17 @@ def async_log_errors[_DenonDeviceT: DenonDevice, **_P, _R](
|
||||
self._receiver.host,
|
||||
)
|
||||
self._attr_available = False
|
||||
except AvrInvalidResponseError, AvrIncompleteResponseError:
|
||||
available = False
|
||||
if self.available:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Denon AVR receiver at host %s returned malformed response. "
|
||||
"Device is unavailable"
|
||||
),
|
||||
self._receiver.host,
|
||||
)
|
||||
self._attr_available = False
|
||||
except AvrCommandError as err:
|
||||
available = False
|
||||
_LOGGER.error(
|
||||
|
||||
@@ -50,7 +50,7 @@ class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Update Duck DNS."""
|
||||
|
||||
retry_after = BACKOFF_INTERVALS[
|
||||
min(self.failed, len(BACKOFF_INTERVALS))
|
||||
min(self.failed, len(BACKOFF_INTERVALS) - 1)
|
||||
].total_seconds()
|
||||
|
||||
try:
|
||||
|
||||
@@ -86,7 +86,6 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
"""Fetch node data from the Duco box."""
|
||||
try:
|
||||
nodes = await self.client.async_get_nodes()
|
||||
lan_info = await self.client.async_get_lan_info()
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -100,7 +99,18 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
# LAN info only backs the diagnostic RSSI sensor, so failures on this
|
||||
# supplemental endpoint, including connection failures, should not make
|
||||
# the primary node entities unavailable.
|
||||
rssi_wifi = self.data.rssi_wifi if self.data else None
|
||||
try:
|
||||
lan_info = await self.client.async_get_lan_info()
|
||||
except DucoError as err:
|
||||
_LOGGER.debug("Could not fetch Duco LAN info", exc_info=err)
|
||||
else:
|
||||
rssi_wifi = lan_info.rssi_wifi
|
||||
|
||||
return DucoData(
|
||||
nodes={node.node_id: node for node in nodes},
|
||||
rssi_wifi=lan_info.rssi_wifi,
|
||||
rssi_wifi=rssi_wifi,
|
||||
)
|
||||
|
||||
@@ -294,6 +294,9 @@
|
||||
"vacuum_raw_get_positions_not_supported": {
|
||||
"message": "Retrieving the positions of the chargers and the device itself is not supported"
|
||||
},
|
||||
"vacuum_send_command_not_supported": {
|
||||
"message": "The {command} command is not supported by {name}"
|
||||
},
|
||||
"vacuum_send_command_params_dict": {
|
||||
"message": "Params must be a dictionary and not a list"
|
||||
},
|
||||
|
||||
@@ -353,11 +353,10 @@ class EcovacsVacuum(
|
||||
if self._capability.clean.action.area is None:
|
||||
info = self._device.device_info
|
||||
name = info.get("nick", info["name"])
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="vacuum_send_command_area_not_supported",
|
||||
translation_placeholders={"name": name},
|
||||
translation_key="vacuum_send_command_not_supported",
|
||||
translation_placeholders={"command": command, "name": name},
|
||||
)
|
||||
|
||||
if command == "spot_area":
|
||||
|
||||
@@ -196,4 +196,6 @@ class EphEmberThermostat(ClimateEntity):
|
||||
@staticmethod
|
||||
def map_mode_eph_hass(operation_mode):
|
||||
"""Map from eph mode to Home Assistant mode."""
|
||||
if operation_mode is None:
|
||||
return HVACMode.HEAT_COOL
|
||||
return EPH_TO_HA_STATE.get(operation_mode.name, HVACMode.HEAT_COOL)
|
||||
|
||||
@@ -284,6 +284,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
|
||||
UpdateDeviceClass, static_info.device_class
|
||||
)
|
||||
|
||||
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
|
||||
"""Return True if latest_version is newer than installed_version.
|
||||
|
||||
ESPHome project versions can carry a build suffix (e.g.
|
||||
2025.11.5_c51f7548) that AwesomeVersion cannot parse. Without stripping
|
||||
it the base comparison raises and the entity is forced on for every
|
||||
build mismatch. Drop the suffix so the versions compare cleanly and we
|
||||
only report genuinely newer firmware.
|
||||
"""
|
||||
return super().version_is_newer(
|
||||
latest_version.partition("_")[0], installed_version.partition("_")[0]
|
||||
)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def installed_version(self) -> str:
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from flexit_bacnet import (
|
||||
OPERATION_MODE_AWAY,
|
||||
OPERATION_MODE_COOKER_HOOD,
|
||||
OPERATION_MODE_FIREPLACE,
|
||||
OPERATION_MODE_HIGH,
|
||||
OPERATION_MODE_HOME,
|
||||
OPERATION_MODE_OFF,
|
||||
OPERATION_MODE_TEMPORARY_HIGH,
|
||||
VENTILATION_MODE_AWAY,
|
||||
VENTILATION_MODE_HIGH,
|
||||
VENTILATION_MODE_HOME,
|
||||
@@ -28,7 +30,9 @@ OPERATION_TO_PRESET_MODE_MAP = {
|
||||
OPERATION_MODE_AWAY: PRESET_AWAY,
|
||||
OPERATION_MODE_HOME: PRESET_HOME,
|
||||
OPERATION_MODE_HIGH: PRESET_HIGH,
|
||||
OPERATION_MODE_COOKER_HOOD: PRESET_HIGH,
|
||||
OPERATION_MODE_FIREPLACE: PRESET_FIREPLACE,
|
||||
OPERATION_MODE_TEMPORARY_HIGH: PRESET_HIGH,
|
||||
}
|
||||
|
||||
# Map preset to ventilation mode (for setting standard modes)
|
||||
|
||||
@@ -938,3 +938,15 @@ class AvmWrapper(FritzBoxTools):
|
||||
"X_AVM-DE_WakeOnLANByMACAddress",
|
||||
NewMACAddress=mac_address,
|
||||
)
|
||||
|
||||
async def async_get_firmware_extra_infos(self) -> dict[str, Any]:
|
||||
"""Return extra infos for firmware."""
|
||||
return await self._async_service_call("UserInterface", "1", "X_AVM-DE_GetInfo")
|
||||
|
||||
async def async_get_device_uptime_hours(self) -> int:
|
||||
"""Get device uptime in hours."""
|
||||
|
||||
def _get_uptime_hours() -> int:
|
||||
return int(self.fritz_status.device_uptime // 3600)
|
||||
|
||||
return await self.hass.async_add_executor_job(_get_uptime_hours)
|
||||
|
||||
@@ -24,9 +24,11 @@ async def async_get_config_entry_diagnostics(
|
||||
"unique_id": avm_wrapper.unique_id.replace(
|
||||
avm_wrapper.unique_id[6:11], "XX:XX"
|
||||
),
|
||||
"device_uptime_hours": await avm_wrapper.async_get_device_uptime_hours(),
|
||||
"current_firmware": avm_wrapper.current_firmware,
|
||||
"latest_firmware": avm_wrapper.latest_firmware,
|
||||
"update_available": avm_wrapper.update_available,
|
||||
"firmware_extra_infos": await avm_wrapper.async_get_firmware_extra_infos(),
|
||||
"connection_type": avm_wrapper.device_conn_type,
|
||||
"is_router": avm_wrapper.device_is_router,
|
||||
"mesh_role": avm_wrapper.mesh_role,
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.0"]
|
||||
"requirements": ["home-assistant-frontend==20260527.1"]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ from requests import RequestException
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -55,6 +59,7 @@ from .const import (
|
||||
PLATFORMS,
|
||||
SUPPORTED_DEVICE_TYPES,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
V1_API_ERROR_RATE_LIMITED,
|
||||
V1_DEVICE_TYPES,
|
||||
)
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
@@ -264,6 +269,10 @@ def get_device_list_v1(
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
|
||||
) from e
|
||||
if e.error_code == V1_API_ERROR_RATE_LIMITED:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Growatt API rate limited, will retry: {e.error_msg or str(e)}"
|
||||
) from e
|
||||
raise ConfigEntryError(
|
||||
f"API error during device list: {e.error_msg or str(e)}"
|
||||
f" (Code: {e.error_code})"
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
import struct
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor import SupervisorBadRequestError, SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
GreenOptions,
|
||||
HomeAssistantOptions,
|
||||
@@ -25,6 +25,7 @@ from homeassistant.components.http import (
|
||||
CONF_SERVER_PORT,
|
||||
CONF_SSL_CERTIFICATE,
|
||||
)
|
||||
from homeassistant.components.onboarding import async_is_onboarded
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
@@ -301,6 +302,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
translation_key="supervisor_not_connected",
|
||||
) from err
|
||||
|
||||
# During onboarding, Supervisor may be out of date. Attempt an update now
|
||||
# so that core loads against an up-to-date Supervisor. A
|
||||
# SupervisorBadRequestError means there is no update available, proceed
|
||||
# normally. No exception means an update was triggered and we must wait for
|
||||
# it to complete. Any other SupervisorError means something unexpected went
|
||||
# wrong and we cannot proceed right now.
|
||||
if not async_is_onboarded(hass):
|
||||
try:
|
||||
await supervisor_client.supervisor.update()
|
||||
except SupervisorBadRequestError:
|
||||
pass # No update available, proceed normally.
|
||||
except SupervisorError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_connected",
|
||||
) from err
|
||||
else:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_update_pending",
|
||||
)
|
||||
|
||||
# Get or create a refresh token for the Supervisor user
|
||||
user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
|
||||
if user.refresh_tokens:
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
},
|
||||
"supervisor_not_connected": {
|
||||
"message": "Not connected with the supervisor / system too busy"
|
||||
},
|
||||
"supervisor_update_pending": {
|
||||
"message": "Supervisor was out-of-date during onboarding. Update triggered, will retry when complete"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"light": {
|
||||
"hue_grouped_light": {
|
||||
"default": "mdi:lightbulb-group",
|
||||
"state": {
|
||||
"off": "mdi:lightbulb-group-off"
|
||||
}
|
||||
},
|
||||
"hue_light": {
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
|
||||
@@ -85,7 +85,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
|
||||
entity_description = LightEntityDescription(
|
||||
key="hue_grouped_light",
|
||||
icon="mdi:lightbulb-group",
|
||||
has_entity_name=True,
|
||||
name=None,
|
||||
)
|
||||
|
||||
@@ -178,17 +178,21 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
@property
|
||||
def max_color_temp_mireds(self) -> int:
|
||||
"""Return the warmest color_temp in mireds that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_maximum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
if (color_temp := self.resource.color_temperature) and (
|
||||
mirek_max := color_temp.mirek_schema.mirek_maximum
|
||||
):
|
||||
return mirek_max
|
||||
# return a fallback value if the light doesn't provide valid limits
|
||||
return FALLBACK_MAX_MIREDS
|
||||
|
||||
@property
|
||||
def min_color_temp_mireds(self) -> int:
|
||||
"""Return the coldest color_temp in mireds that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_minimum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
if (color_temp := self.resource.color_temperature) and (
|
||||
mirek_min := color_temp.mirek_schema.mirek_minimum
|
||||
):
|
||||
return mirek_min
|
||||
# return a fallback value if the light doesn't provide valid limits
|
||||
return FALLBACK_MIN_MIREDS
|
||||
|
||||
@property
|
||||
|
||||
@@ -4,7 +4,7 @@ from logging import getLogger
|
||||
|
||||
from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse
|
||||
from aioimmich.assets.models import ImmichAsset
|
||||
from aioimmich.exceptions import ImmichError
|
||||
from aioimmich.exceptions import ImmichError, ImmichForbiddenError
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.media_player import BrowseError, MediaClass
|
||||
@@ -79,7 +79,7 @@ class ImmichMediaSource(MediaSource):
|
||||
],
|
||||
)
|
||||
|
||||
async def _async_build_immich(
|
||||
async def _async_build_immich( # noqa: C901
|
||||
self, item: MediaSourceItem, entries: list[ConfigEntry]
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Handle browsing different immich instances."""
|
||||
@@ -137,6 +137,12 @@ class ImmichMediaSource(MediaSource):
|
||||
LOGGER.debug("Render all albums for %s", entry.title)
|
||||
try:
|
||||
albums = await immich_api.albums.async_get_all_albums()
|
||||
except ImmichForbiddenError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_api_permission",
|
||||
translation_placeholders={"msg": str(err)},
|
||||
) from err
|
||||
except ImmichError:
|
||||
return []
|
||||
|
||||
@@ -158,6 +164,12 @@ class ImmichMediaSource(MediaSource):
|
||||
LOGGER.debug("Render all tags for %s", entry.title)
|
||||
try:
|
||||
tags = await immich_api.tags.async_get_all_tags()
|
||||
except ImmichForbiddenError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_api_permission",
|
||||
translation_placeholders={"msg": str(err)},
|
||||
) from err
|
||||
except ImmichError:
|
||||
return []
|
||||
|
||||
@@ -178,6 +190,12 @@ class ImmichMediaSource(MediaSource):
|
||||
LOGGER.debug("Render all people for %s", entry.title)
|
||||
try:
|
||||
people = await immich_api.people.async_get_all_people()
|
||||
except ImmichForbiddenError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_api_permission",
|
||||
translation_placeholders={"msg": str(err)},
|
||||
) from err
|
||||
except ImmichError:
|
||||
return []
|
||||
|
||||
@@ -211,6 +229,12 @@ class ImmichMediaSource(MediaSource):
|
||||
identifier.collection_id
|
||||
)
|
||||
assets = album_info.assets
|
||||
except ImmichForbiddenError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_api_permission",
|
||||
translation_placeholders={"msg": str(err)},
|
||||
) from err
|
||||
except ImmichError:
|
||||
return []
|
||||
|
||||
@@ -223,6 +247,12 @@ class ImmichMediaSource(MediaSource):
|
||||
assets = await immich_api.search.async_get_all_by_tag_ids(
|
||||
[identifier.collection_id]
|
||||
)
|
||||
except ImmichForbiddenError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_api_permission",
|
||||
translation_placeholders={"msg": str(err)},
|
||||
) from err
|
||||
except ImmichError:
|
||||
return []
|
||||
|
||||
@@ -235,12 +265,24 @@ class ImmichMediaSource(MediaSource):
|
||||
assets = await immich_api.search.async_get_all_by_person_ids(
|
||||
[identifier.collection_id]
|
||||
)
|
||||
except ImmichForbiddenError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_api_permission",
|
||||
translation_placeholders={"msg": str(err)},
|
||||
) from err
|
||||
except ImmichError:
|
||||
return []
|
||||
elif identifier.collection == "favorites":
|
||||
LOGGER.debug("Render all assets for favorites collection")
|
||||
try:
|
||||
assets = await immich_api.search.async_get_all_favorites()
|
||||
except ImmichForbiddenError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_api_permission",
|
||||
translation_placeholders={"msg": str(err)},
|
||||
) from err
|
||||
except ImmichError:
|
||||
return []
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ async def _async_upload_file(service_call: ServiceCall) -> None:
|
||||
await coordinator.api.albums.async_add_assets_to_album(
|
||||
target_album, [upload_result.asset_id]
|
||||
)
|
||||
except ImmichError as ex:
|
||||
except (ImmichError, FileNotFoundError) as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="upload_failed",
|
||||
|
||||
@@ -102,6 +102,9 @@
|
||||
"identifier_unresolvable": {
|
||||
"message": "Could not parse identifier: {identifier}"
|
||||
},
|
||||
"missing_api_permission": {
|
||||
"message": "Missing API permission ({msg})."
|
||||
},
|
||||
"not_configured": {
|
||||
"message": "Immich is not configured."
|
||||
},
|
||||
|
||||
@@ -46,6 +46,8 @@ BINARY_SENSORS: Final = (
|
||||
key=IndevoltSystem.HEATING_STATE,
|
||||
generation=(1,),
|
||||
translation_key="electric_heating_state",
|
||||
on_value=1000,
|
||||
off_value=1001,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
|
||||
@@ -939,12 +939,24 @@ class IndevoltSensorEntity(IndevoltEntity, SensorEntity):
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return False when the device is not in the required energy mode."""
|
||||
"""Return False for sensors in a non-applicable state."""
|
||||
|
||||
# Check whether device is not in the required energy mode
|
||||
if self.entity_description.energy_mode is not None:
|
||||
energy_mode = self.coordinator.data.get(IndevoltConfig.READ_ENERGY_MODE)
|
||||
if energy_mode != self.entity_description.energy_mode:
|
||||
return False
|
||||
|
||||
# Check whether inverter is reporting 0 degrees with heater not active (thus reporting to indicate "idle")
|
||||
# Pending fix by Indevolt: https://discord.com/channels/1417471269942591571/1510277757689659522
|
||||
if self.entity_description.key == IndevoltBattery.GEN_1_INVERTER_TEMPERATURE:
|
||||
inverter_temp = self.coordinator.data.get(
|
||||
IndevoltBattery.GEN_1_INVERTER_TEMPERATURE
|
||||
)
|
||||
heating_state = self.coordinator.data.get(IndevoltSystem.HEATING_STATE)
|
||||
if inverter_temp == 0 and heating_state != 1000:
|
||||
return False
|
||||
|
||||
return super().available
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["iometer==0.4.0"],
|
||||
"requirements": ["iometer==1.0.1"],
|
||||
"zeroconf": ["_iometer._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ async def async_setup_entry(
|
||||
async_add_entities(device.zones.values())
|
||||
|
||||
# create any components not yet created
|
||||
for controller in disco.pi_disco.controllers.values():
|
||||
for controller in (await disco.pi_disco.fetch_controllers()).values():
|
||||
init_controller(controller)
|
||||
|
||||
# connect to register any further components
|
||||
|
||||
@@ -29,12 +29,13 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
async with asyncio.timeout(TIMEOUT_DISCOVERY):
|
||||
await controller_ready.wait()
|
||||
|
||||
if not disco.pi_disco.controllers:
|
||||
controllers = await disco.pi_disco.fetch_controllers()
|
||||
if not controllers:
|
||||
await async_stop_discovery_service(hass)
|
||||
_LOGGER.debug("No controllers found")
|
||||
return False
|
||||
|
||||
_LOGGER.debug("Controllers %s", disco.pi_disco.controllers)
|
||||
_LOGGER.debug("Controllers %s", controllers)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -52,11 +52,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
||||
"""Set up Jellyfin media source."""
|
||||
# Currently only a single Jellyfin server is supported
|
||||
entry: JellyfinConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return JellyfinSource(hass, coordinator.api_client, entry)
|
||||
return JellyfinSource(hass)
|
||||
|
||||
|
||||
class JellyfinSource(MediaSource):
|
||||
@@ -64,21 +60,28 @@ class JellyfinSource(MediaSource):
|
||||
|
||||
name: str = "Jellyfin"
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, client: JellyfinClient, entry: JellyfinConfigEntry
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the Jellyfin media source."""
|
||||
super().__init__(DOMAIN)
|
||||
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.entry: JellyfinConfigEntry
|
||||
self.client: JellyfinClient
|
||||
self.api: Any
|
||||
self.url: str
|
||||
|
||||
self.client = client
|
||||
self.api = client.jellyfin
|
||||
self.url = jellyfin_url(client, "")
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure the Jellyfin integration is loaded and set up instance state."""
|
||||
if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)):
|
||||
raise BrowseError("Jellyfin integration not loaded")
|
||||
entry: JellyfinConfigEntry = entries[0]
|
||||
self.entry = entry
|
||||
self.client = entry.runtime_data.api_client
|
||||
self.api = self.client.jellyfin
|
||||
self.url = jellyfin_url(self.client, "")
|
||||
|
||||
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Return a streamable URL and associated mime type."""
|
||||
self._ensure_loaded()
|
||||
media_item = await self.hass.async_add_executor_job(
|
||||
self.api.get_item, item.identifier
|
||||
)
|
||||
@@ -94,6 +97,7 @@ class JellyfinSource(MediaSource):
|
||||
|
||||
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
|
||||
"""Return a browsable Jellyfin media source."""
|
||||
self._ensure_loaded()
|
||||
if not item.identifier:
|
||||
return await self._build_libraries()
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class LGTVRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
port = user_input[CONF_DEVICE]
|
||||
set_id = user_input[CONF_SET_ID]
|
||||
set_id = int(user_input[CONF_SET_ID])
|
||||
|
||||
self._async_abort_entries_match({CONF_DEVICE: port, CONF_SET_ID: set_id})
|
||||
error = await _async_attempt_connect(port, set_id)
|
||||
|
||||
@@ -108,5 +108,7 @@ def create_matter_ble_proxy(hass: HomeAssistant, ws_url: str) -> MatterBleProxy:
|
||||
ws_url=ws_url,
|
||||
scan_source=HaBluetoothScanSource(hass),
|
||||
device_resolver=HaBluetoothDeviceResolver(hass),
|
||||
task_factory=hass.async_create_task,
|
||||
task_factory=lambda coro: hass.async_create_background_task(
|
||||
coro, name="matter_ble_proxy"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1295,7 +1295,7 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
data, content_type = await player.async_get_media_image()
|
||||
|
||||
if data is None:
|
||||
return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
headers: LooseHeaders = {CACHE_CONTROL: "max-age=3600"}
|
||||
return web.Response(body=data, content_type=content_type, headers=headers)
|
||||
|
||||
@@ -80,5 +80,12 @@ class MeteoAlertBinarySensor(BinarySensorEntity):
|
||||
expiration_date = dt_util.parse_datetime(alert["expires"])
|
||||
|
||||
if expiration_date is not None and expiration_date > dt_util.utcnow():
|
||||
self._attr_extra_state_attributes = alert
|
||||
self._attr_extra_state_attributes = {
|
||||
key: (
|
||||
value.encode("utf-8", errors="replace").decode("utf-8")
|
||||
if isinstance(value, str)
|
||||
else value
|
||||
)
|
||||
for key, value in alert.items()
|
||||
}
|
||||
self._attr_is_on = True
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_DISCOVERY,
|
||||
CONF_PLATFORM,
|
||||
CONF_PORT,
|
||||
CONF_PROTOCOL,
|
||||
SERVICE_RELOAD,
|
||||
)
|
||||
@@ -50,6 +51,7 @@ from .client import (
|
||||
async_subscribe_internal,
|
||||
publish,
|
||||
subscribe,
|
||||
try_connection,
|
||||
)
|
||||
from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA
|
||||
from .config_integration import CONFIG_SCHEMA_BASE
|
||||
@@ -79,14 +81,15 @@ from .const import (
|
||||
CONFIG_ENTRY_VERSION,
|
||||
DEFAULT_DISCOVERY,
|
||||
DEFAULT_ENCODING,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_PREFIX,
|
||||
DEFAULT_PROTOCOL,
|
||||
DEFAULT_QOS,
|
||||
DEFAULT_RETAIN,
|
||||
DOMAIN,
|
||||
ENTITY_PLATFORMS,
|
||||
ENTRY_OPTION_FIELDS,
|
||||
MQTT_CONNECTION_STATE,
|
||||
PROTOCOL_5,
|
||||
PROTOCOL_311,
|
||||
TEMPLATE_ERRORS,
|
||||
Platform,
|
||||
@@ -496,25 +499,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Load a config entry."""
|
||||
mqtt_data: MqttData
|
||||
|
||||
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL:
|
||||
broker: str = entry.data[CONF_BROKER]
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"protocol_5_migration",
|
||||
issue_domain=DOMAIN,
|
||||
is_fixable=True,
|
||||
breaks_in_ha_version="2027.1.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol",
|
||||
data={
|
||||
"entry_id": entry.entry_id,
|
||||
"broker": broker,
|
||||
"protocol": protocol,
|
||||
},
|
||||
translation_placeholders={"broker": broker, "protocol": protocol},
|
||||
translation_key="protocol_5_migration",
|
||||
)
|
||||
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != PROTOCOL_5:
|
||||
# Automatically migrate the broker protocol to v5 if possible
|
||||
# Can be removed with HA Core 2027.1
|
||||
new_entry_data = entry.data.copy()
|
||||
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
|
||||
# Try the connection with protocol version 5
|
||||
# And update the protocol if successful
|
||||
if await hass.async_add_executor_job(
|
||||
try_connection,
|
||||
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
|
||||
):
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=new_entry_data,
|
||||
)
|
||||
ir.async_delete_issue(hass, DOMAIN, "protocol_5_migration")
|
||||
_LOGGER.info(
|
||||
"The MQTT protocol version was successfully updated to version 5"
|
||||
)
|
||||
else:
|
||||
broker: str = entry.data[CONF_BROKER]
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"protocol_5_migration",
|
||||
issue_domain=DOMAIN,
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2027.1.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/mqtt/"
|
||||
"#mqtt-protocol",
|
||||
data={
|
||||
"entry_id": entry.entry_id,
|
||||
"broker": broker,
|
||||
"protocol": protocol,
|
||||
},
|
||||
translation_placeholders={"broker": broker, "protocol": protocol},
|
||||
translation_key="protocol_5_migration",
|
||||
)
|
||||
|
||||
async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
|
||||
"""Set up the MQTT client."""
|
||||
|
||||
@@ -9,6 +9,7 @@ from functools import lru_cache, partial
|
||||
from itertools import chain, groupby
|
||||
import logging
|
||||
from operator import attrgetter
|
||||
import queue
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
@@ -92,6 +93,8 @@ from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabl
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MQTT_TIMEOUT = 5
|
||||
|
||||
MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails
|
||||
PREFERRED_BUFFER_SIZE = 8 * 1024 * 1024 # Set receive buffer size to 8MiB
|
||||
|
||||
@@ -433,6 +436,40 @@ class MqttClientSetup:
|
||||
return self._client
|
||||
|
||||
|
||||
def try_connection(
|
||||
user_input: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Test if we can connect to an MQTT broker."""
|
||||
mqtt_client_setup = MqttClientSetup(user_input)
|
||||
mqtt_client_setup.setup()
|
||||
client = mqtt_client_setup.client
|
||||
|
||||
result: queue.Queue[bool] = queue.Queue(maxsize=1)
|
||||
|
||||
def on_connect(
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
_connect_flags: mqtt.ConnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None = None,
|
||||
) -> None:
|
||||
"""Handle connection result."""
|
||||
result.put(not reason_code.is_failure)
|
||||
|
||||
client.on_connect = on_connect
|
||||
|
||||
client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT])
|
||||
client.loop_start()
|
||||
|
||||
try:
|
||||
return result.get(timeout=MQTT_TIMEOUT)
|
||||
except queue.Empty:
|
||||
return False
|
||||
finally:
|
||||
client.disconnect()
|
||||
client.loop_stop()
|
||||
|
||||
|
||||
class MQTT:
|
||||
"""Home Assistant MQTT client."""
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -22,7 +21,6 @@ from cryptography.hazmat.primitives.serialization import (
|
||||
load_pem_private_key,
|
||||
)
|
||||
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
|
||||
import paho.mqtt.client as mqtt
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
@@ -143,7 +141,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
from .addon import get_addon_manager
|
||||
from .client import MqttClientSetup
|
||||
from .client import try_connection
|
||||
from .const import (
|
||||
ALARM_CONTROL_PANEL_SUPPORTED_FEATURES,
|
||||
ATTR_PAYLOAD,
|
||||
@@ -444,8 +442,6 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 5
|
||||
|
||||
CONF_CLIENT_KEY_PASSWORD = "client_key_password"
|
||||
|
||||
MQTT_TIMEOUT = 5
|
||||
|
||||
ADVANCED_OPTIONS = "advanced_options"
|
||||
SET_CA_CERT = "set_ca_cert"
|
||||
SET_CLIENT_CERT = "set_client_cert"
|
||||
@@ -5457,7 +5453,6 @@ async def async_get_broker_settings(
|
||||
or current_client_certificate
|
||||
or current_client_key
|
||||
or current_tls_insecure
|
||||
or current_protocol != DEFAULT_PROTOCOL
|
||||
or current_config.get(SET_CA_CERT, "off") != "off"
|
||||
or current_config.get(SET_CLIENT_CERT)
|
||||
or current_transport == TRANSPORT_WEBSOCKETS
|
||||
@@ -5466,6 +5461,12 @@ async def async_get_broker_settings(
|
||||
# Build form
|
||||
fields[vol.Required(CONF_BROKER, default=current_broker)] = TEXT_SELECTOR
|
||||
fields[vol.Required(CONF_PORT, default=current_port)] = PORT_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_PROTOCOL,
|
||||
description={"suggested_value": current_protocol},
|
||||
)
|
||||
] = PROTOCOL_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
@@ -5556,12 +5557,6 @@ async def async_get_broker_settings(
|
||||
description={"suggested_value": current_tls_insecure},
|
||||
)
|
||||
] = BOOLEAN_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_PROTOCOL,
|
||||
description={"suggested_value": current_protocol},
|
||||
)
|
||||
] = PROTOCOL_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_TRANSPORT,
|
||||
@@ -5582,40 +5577,6 @@ async def async_get_broker_settings(
|
||||
return False
|
||||
|
||||
|
||||
def try_connection(
|
||||
user_input: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Test if we can connect to an MQTT broker."""
|
||||
mqtt_client_setup = MqttClientSetup(user_input)
|
||||
mqtt_client_setup.setup()
|
||||
client = mqtt_client_setup.client
|
||||
|
||||
result: queue.Queue[bool] = queue.Queue(maxsize=1)
|
||||
|
||||
def on_connect(
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
_connect_flags: mqtt.ConnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None = None,
|
||||
) -> None:
|
||||
"""Handle connection result."""
|
||||
result.put(not reason_code.is_failure)
|
||||
|
||||
client.on_connect = on_connect
|
||||
|
||||
client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT])
|
||||
client.loop_start()
|
||||
|
||||
try:
|
||||
return result.get(timeout=MQTT_TIMEOUT)
|
||||
except queue.Empty:
|
||||
return False
|
||||
finally:
|
||||
client.disconnect()
|
||||
client.loop_stop()
|
||||
|
||||
|
||||
def check_certicate_chain() -> str | None:
|
||||
"""Check the MQTT certificates."""
|
||||
if client_certificate := get_file_path(CONF_CLIENT_CERT):
|
||||
|
||||
@@ -175,10 +175,10 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
|
||||
self._attr_latitude = None
|
||||
self._attr_longitude = None
|
||||
_LOGGER.warning(
|
||||
"Extra state attributes received at % and template %s "
|
||||
"Extra state attributes received at %s and template %s "
|
||||
"contain invalid or incomplete location info. Got %s",
|
||||
self._config.get(CONF_JSON_ATTRS_TEMPLATE),
|
||||
self._config.get(CONF_JSON_ATTRS_TOPIC),
|
||||
self._config.get(CONF_JSON_ATTRS_TEMPLATE),
|
||||
extra_state_attributes,
|
||||
)
|
||||
|
||||
@@ -190,11 +190,11 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
|
||||
self._attr_location_accuracy = gps_accuracy
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Extra state attributes received at % and template %s "
|
||||
"Extra state attributes received at %s and template %s "
|
||||
"contain invalid GPS accuracy setting, "
|
||||
"gps_accuracy was set to 0 as the default. Got %s",
|
||||
self._config.get(CONF_JSON_ATTRS_TEMPLATE),
|
||||
self._config.get(CONF_JSON_ATTRS_TOPIC),
|
||||
self._config.get(CONF_JSON_ATTRS_TEMPLATE),
|
||||
extra_state_attributes,
|
||||
)
|
||||
self._attr_location_accuracy = 0
|
||||
|
||||
@@ -530,7 +530,7 @@ class MqttAttributesMixin(Entity):
|
||||
self._attributes_message_received,
|
||||
{
|
||||
"_attr_extra_state_attributes",
|
||||
"_attr_gps_accuracy",
|
||||
"_attr_location_accuracy",
|
||||
"_attr_latitude",
|
||||
"_attr_location_name",
|
||||
"_attr_longitude",
|
||||
|
||||
@@ -5,12 +5,10 @@ from typing import TYPE_CHECKING
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.const import CONF_PORT, CONF_PROTOCOL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .config_flow import try_connection
|
||||
from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5
|
||||
from .const import DOMAIN
|
||||
|
||||
URL_MQTT_BROKER_CONFIGURATION = (
|
||||
"https://www.home-assistant.io/integrations/mqtt/#broker-configuration"
|
||||
@@ -55,55 +53,6 @@ class MQTTDeviceEntryMigration(RepairsFlow):
|
||||
)
|
||||
|
||||
|
||||
class MQTTProtocolV5Migration(RepairsFlow):
|
||||
"""Handler to migrate to MQTT protocol version 5."""
|
||||
|
||||
def __init__(self, entry_id: str, broker: str, protocol: str) -> None:
|
||||
"""Initialize the flow."""
|
||||
self.entry_id = entry_id
|
||||
self.broker = broker
|
||||
self.protocol = protocol
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
entry = self.hass.config_entries.async_get_entry(self.entry_id)
|
||||
if TYPE_CHECKING:
|
||||
assert entry is not None
|
||||
new_entry_data = entry.data.copy()
|
||||
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
|
||||
# Try the connection with protocol version 5
|
||||
if await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
|
||||
):
|
||||
self.hass.config_entries.async_update_entry(entry, data=new_entry_data)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
return self.async_abort(
|
||||
reason="mqtt_broker_migration_to_v5_failed",
|
||||
description_placeholders={
|
||||
"broker": self.broker,
|
||||
"protocol": self.protocol,
|
||||
"url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders={"broker": self.broker, "protocol": self.protocol},
|
||||
)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
@@ -113,10 +62,6 @@ async def async_create_fix_flow(
|
||||
if TYPE_CHECKING:
|
||||
assert data is not None
|
||||
entry_id: str = data["entry_id"] # type: ignore[assignment]
|
||||
if issue_id == "protocol_5_migration":
|
||||
broker: str = data["broker"] # type: ignore[assignment]
|
||||
protocol: str = data["protocol"] # type: ignore[assignment]
|
||||
return MQTTProtocolV5Migration(entry_id, broker, protocol)
|
||||
subentry_id: str = data["subentry_id"] # type: ignore[assignment]
|
||||
name: str = data["name"] # type: ignore[assignment]
|
||||
return MQTTDeviceEntryMigration(
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"keepalive": "A value less than 90 seconds is advised.",
|
||||
"password": "The password to log in to your MQTT broker.",
|
||||
"port": "The port your MQTT broker listens to. For example 1883.",
|
||||
"protocol": "The MQTT protocol your broker operates at. For example 3.1.1.",
|
||||
"protocol": "The MQTT protocol version that is used. Note that Home Assistant will silently change to version 5 if your broker supports it.",
|
||||
"set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT broker's certificate.",
|
||||
"set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.",
|
||||
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
|
||||
@@ -1134,18 +1134,8 @@
|
||||
"title": "Invalid config found for MQTT {domain} item"
|
||||
},
|
||||
"protocol_5_migration": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue."
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Home Assistant is migrating to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.",
|
||||
"title": "MQTT protocol change required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Deprecated MQTT protocol {protocol} in use"
|
||||
"description": "The automatic migration to MQTT protocol version 5 failed. The currently configured protocol version for MQTT broker {broker} is {protocol}, but this protocol version is deprecated, and support for it will be removed.\n\nMake sure your broker supports protocol version 5. Update your MQTT broker's connection settings, and restart Home Assistant to fix this issue.",
|
||||
"title": "MQTT protocol migration failed"
|
||||
},
|
||||
"subentry_migration_discovery": {
|
||||
"fix_flow": {
|
||||
|
||||
@@ -271,7 +271,7 @@ class MqttValve(MqttEntity, ValveEntity):
|
||||
self._range, float(position_payload)
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
_LOGGER.debug(
|
||||
"Ignoring non numeric payload '%s' received on topic '%s'",
|
||||
position_payload,
|
||||
msg.topic,
|
||||
@@ -279,9 +279,9 @@ class MqttValve(MqttEntity, ValveEntity):
|
||||
else:
|
||||
percentage_payload = min(max(percentage_payload, 0), 100)
|
||||
self._attr_current_valve_position = percentage_payload
|
||||
# Reset closing and opening if the valve is fully opened or fully closed
|
||||
if state is None and percentage_payload in (0, 100):
|
||||
state = RESET_CLOSING_OPENING
|
||||
# Reset opening/closing when a position update is received
|
||||
# without an explicit opening/closing transitional state.
|
||||
state = state or RESET_CLOSING_OPENING
|
||||
position_set = True
|
||||
if state_payload and state is None and not position_set:
|
||||
_LOGGER.warning(
|
||||
@@ -291,8 +291,6 @@ class MqttValve(MqttEntity, ValveEntity):
|
||||
state_payload,
|
||||
)
|
||||
return
|
||||
if state is None:
|
||||
return
|
||||
self._update_state(state)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynintendoauth", "pynintendoparental"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.4"]
|
||||
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.4.0"]
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ class OMIEPriceSensor(CoordinatorEntity[OMIECoordinator], SensorEntity):
|
||||
self._attr_unique_id = pyomie_series_name
|
||||
self._pyomie_series_name = pyomie_series_name
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._handle_coordinator_update()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Update this sensor's state from the coordinator results."""
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
||||
"requirements": [
|
||||
"onvif-zeep-async==4.1.0",
|
||||
"onvif-zeep-async==4.1.1",
|
||||
"onvif_parsers==2.3.0",
|
||||
"WSDiscovery==2.1.2"
|
||||
]
|
||||
|
||||
+1
@@ -57,6 +57,7 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = {
|
||||
OverkizCommandParam.STANDBY: HVACMode.OFF, # main command
|
||||
OverkizCommandParam.AUTO: HVACMode.AUTO,
|
||||
OverkizCommandParam.EXTERNAL: HVACMode.AUTO,
|
||||
OverkizCommandParam.PROG: HVACMode.AUTO,
|
||||
OverkizCommandParam.INTERNAL: HVACMode.AUTO, # main command
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ async def on_device_available(
|
||||
coordinator: OverkizDataUpdateCoordinator, event: Event
|
||||
) -> None:
|
||||
"""Handle device available event."""
|
||||
if event.device_url:
|
||||
if event.device_url and event.device_url in coordinator.devices:
|
||||
coordinator.devices[event.device_url].available = True
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ async def on_device_unavailable_disabled(
|
||||
coordinator: OverkizDataUpdateCoordinator, event: Event
|
||||
) -> None:
|
||||
"""Handle device unavailable / disabled event."""
|
||||
if event.device_url:
|
||||
if event.device_url and event.device_url in coordinator.devices:
|
||||
coordinator.devices[event.device_url].available = False
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ async def on_device_state_changed(
|
||||
coordinator: OverkizDataUpdateCoordinator, event: Event
|
||||
) -> None:
|
||||
"""Handle device state changed event."""
|
||||
if not event.device_url:
|
||||
if not event.device_url or event.device_url not in coordinator.devices:
|
||||
return
|
||||
|
||||
for state in event.device_states:
|
||||
@@ -198,7 +198,7 @@ async def on_device_removed(
|
||||
):
|
||||
registry.async_remove_device(registered_device.id)
|
||||
|
||||
if event.device_url:
|
||||
if event.device_url and event.device_url in coordinator.devices:
|
||||
del coordinator.devices[event.device_url]
|
||||
|
||||
|
||||
|
||||
@@ -854,6 +854,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
|
||||
if current_value is None or target_value is None:
|
||||
return None
|
||||
|
||||
if current_value in (_POSITION_MY, _POSITION_UNKNOWN):
|
||||
return None
|
||||
|
||||
return current_value - target_value
|
||||
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["rf-protocols==4.0.0"]
|
||||
"requirements": ["rf-protocols==4.0.1"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["renault_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["renault-api==0.5.10"]
|
||||
"requirements": ["renault-api==0.5.11"]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,11 @@ class IRobotEntity(Entity):
|
||||
model=self.vacuum_state.get("sku"),
|
||||
name=str(self.vacuum_state.get("name")),
|
||||
sw_version=self.vacuum_state.get("softwareVer"),
|
||||
hw_version=self.vacuum_state.get("hardwareRev"),
|
||||
hw_version=(
|
||||
str(hw_rev)
|
||||
if (hw_rev := self.vacuum_state.get("hardwareRev")) is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
if mac_address := self.vacuum_state.get("hwPartsRev", {}).get(
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"requirements": [
|
||||
"getmac==0.9.5",
|
||||
"samsungctl[websocket]==0.7.1",
|
||||
"samsungtvws[async,encrypted]==2.7.2",
|
||||
"samsungtvws[async,encrypted]==3.0.5",
|
||||
"wakeonlan==3.3.0",
|
||||
"async-upnp-client==0.46.2"
|
||||
],
|
||||
|
||||
@@ -42,4 +42,8 @@ class SchlageEntity(CoordinatorEntity[SchlageDataUpdateCoordinator]):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.device_id in self.coordinator.data.locks
|
||||
return (
|
||||
super().available
|
||||
and self.device_id in self.coordinator.data.locks
|
||||
and self._lock.connected
|
||||
)
|
||||
|
||||
@@ -244,7 +244,8 @@ def async_restore_rpc_attribute_entities(
|
||||
sensor_class: Callable,
|
||||
) -> None:
|
||||
"""Restore RPC attributes entities."""
|
||||
entities = []
|
||||
entities: list[Entity] = []
|
||||
sleep_period = config_entry.data[CONF_SLEEP_PERIOD]
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
|
||||
@@ -259,11 +260,13 @@ def async_restore_rpc_attribute_entities(
|
||||
attribute = entry.unique_id.split("-")[-1]
|
||||
|
||||
if description := sensors.get(attribute):
|
||||
entities.append(
|
||||
get_entity_class(sensor_class, description)(
|
||||
coordinator, key, attribute, description, entry
|
||||
entity_class = get_entity_class(sensor_class, description)
|
||||
if sleep_period:
|
||||
entities.append(
|
||||
entity_class(coordinator, key, attribute, description, entry)
|
||||
)
|
||||
)
|
||||
else:
|
||||
entities.append(entity_class(coordinator, key, attribute, description))
|
||||
|
||||
if not entities:
|
||||
return
|
||||
|
||||
@@ -132,6 +132,9 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
|
||||
|
||||
device = entry.runtime_data.rpc.device
|
||||
|
||||
if not device.initialized:
|
||||
return
|
||||
|
||||
if (
|
||||
(ws_config := device.config.get("ws"))
|
||||
and ws_config["enable"]
|
||||
@@ -169,6 +172,9 @@ def async_manage_open_wifi_ap_issue(
|
||||
|
||||
device = entry.runtime_data.rpc.device
|
||||
|
||||
if not device.initialized:
|
||||
return
|
||||
|
||||
# Check if WiFi AP is enabled and is open (no password)
|
||||
if (
|
||||
(wifi_config := device.config.get("wifi"))
|
||||
|
||||
@@ -72,8 +72,10 @@ async def async_setup_entry(
|
||||
for device in entry_data.devices.values()
|
||||
for component in device.status
|
||||
if (
|
||||
Capability.SWITCH in device.status[MAIN]
|
||||
and any(capability in device.status[MAIN] for capability in CAPABILITIES)
|
||||
Capability.SWITCH in device.status[component]
|
||||
and any(
|
||||
capability in device.status[component] for capability in CAPABILITIES
|
||||
)
|
||||
and Capability.SAMSUNG_CE_LAMP not in device.status[component]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
import switchbot
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.components.sensor import ConfigType
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -310,6 +311,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) ->
|
||||
translation_placeholders={
|
||||
"sensor_type": entry.data[CONF_SENSOR_TYPE],
|
||||
"address": entry.data[CONF_ADDRESS],
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
entry.data[CONF_ADDRESS].upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -331,7 +337,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) ->
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found_error",
|
||||
translation_placeholders={"sensor_type": sensor_type, "address": address},
|
||||
translation_placeholders={
|
||||
"sensor_type": sensor_type,
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION
|
||||
if connectable
|
||||
else BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice)
|
||||
|
||||
@@ -384,7 +384,7 @@
|
||||
"message": "The device ID {device_id} does not belong to SwitchBot integration."
|
||||
},
|
||||
"device_not_found_error": {
|
||||
"message": "Could not find Switchbot {sensor_type} with address {address}"
|
||||
"message": "Could not find Switchbot {sensor_type} with address {address}: {reason}"
|
||||
},
|
||||
"device_without_config_entry": {
|
||||
"message": "The device ID {device_id} is not associated with a config entry."
|
||||
|
||||
@@ -40,6 +40,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
login_task: asyncio.Task | None = None
|
||||
refresh_token: str | None = None
|
||||
tado: Tado | None = None
|
||||
tado_device_url: str = ""
|
||||
user_code: str = ""
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
@@ -69,8 +71,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Error while initiating Tado")
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
assert self.tado is not None
|
||||
tado_device_url = self.tado.device_verification_url()
|
||||
user_code = URL(tado_device_url).query["user_code"]
|
||||
self.tado_device_url = self.tado.device_verification_url()
|
||||
self.user_code = URL(self.tado_device_url).query["user_code"]
|
||||
|
||||
async def _wait_for_login() -> None:
|
||||
"""Wait for the user to login."""
|
||||
@@ -119,8 +121,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user",
|
||||
progress_action="wait_for_device",
|
||||
description_placeholders={
|
||||
"url": tado_device_url,
|
||||
"code": user_code,
|
||||
"url": self.tado_device_url,
|
||||
"code": self.user_code,
|
||||
},
|
||||
progress_task=self.login_task,
|
||||
)
|
||||
|
||||
@@ -36,6 +36,11 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]):
|
||||
via_device=(DOMAIN, coordinator.bridge.serial),
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self._lock.is_connected
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
|
||||
@@ -89,11 +89,7 @@ class TedeeLockEntity(TedeeEntity, LockEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self._lock.is_connected
|
||||
and self._lock.state != TedeeLockState.UNCALIBRATED
|
||||
)
|
||||
return super().available and self._lock.state != TedeeLockState.UNCALIBRATED
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the door."""
|
||||
|
||||
@@ -102,14 +102,12 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity):
|
||||
"""Return the current state of the burner."""
|
||||
return {"heating_type": self.coordinator.data.agreement.heating_type}
|
||||
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
@toon_exception_handler
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Change the setpoint of the thermostat."""
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
await self.coordinator.toon.set_current_setpoint(temperature)
|
||||
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
@toon_exception_handler
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Helpers for Toon."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from toonapi import ToonConnectionError, ToonError
|
||||
|
||||
from .entity import ToonEntity
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import DOMAIN
|
||||
from .entity import ToonEntity
|
||||
|
||||
|
||||
def toon_exception_handler[_ToonEntityT: ToonEntity, **_P](
|
||||
@@ -17,20 +17,24 @@ def toon_exception_handler[_ToonEntityT: ToonEntity, **_P](
|
||||
"""Decorate Toon calls to handle Toon exceptions.
|
||||
|
||||
A decorator that wraps the passed in function, catches Toon errors,
|
||||
and handles the availability of the device in the data coordinator.
|
||||
and raises a translated ``HomeAssistantError``.
|
||||
"""
|
||||
|
||||
async def handler(self: _ToonEntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
except ToonConnectionError as error:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
self.coordinator.last_update_success = False
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
) from error
|
||||
except ToonError as error:
|
||||
_LOGGER.error("Invalid response from API: %s", error)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_response",
|
||||
) from error
|
||||
|
||||
return handler
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"communication_error": {
|
||||
"message": "An error occurred while communicating with the Toon device."
|
||||
},
|
||||
"invalid_response": {
|
||||
"message": "Received an invalid response from the Toon device."
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ class ToonSwitch(ToonEntity, SwitchEntity):
|
||||
class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity):
|
||||
"""Defines a Toon program switch."""
|
||||
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
@toon_exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the Toon program switch."""
|
||||
@@ -68,7 +67,6 @@ class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity):
|
||||
ACTIVE_STATE_AWAY, PROGRAM_STATE_OFF
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
@toon_exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the Toon program switch."""
|
||||
@@ -80,7 +78,6 @@ class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity):
|
||||
class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity):
|
||||
"""Defines a Toon Holiday mode switch."""
|
||||
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
@toon_exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the Toon holiday mode switch."""
|
||||
@@ -88,7 +85,6 @@ class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity):
|
||||
ACTIVE_STATE_AWAY, PROGRAM_STATE_ON
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
@toon_exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the Toon holiday mode switch."""
|
||||
|
||||
@@ -34,6 +34,7 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity):
|
||||
"""Tractive device tracker."""
|
||||
|
||||
_attr_translation_key = "tracker"
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, client: TractiveClient, item: Trackables) -> None:
|
||||
"""Initialize tracker entity."""
|
||||
|
||||
@@ -29,7 +29,8 @@ class TractiveEntity(Entity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
configuration_url="https://my.tractive.com/",
|
||||
identifiers={(DOMAIN, tracker_details["_id"])},
|
||||
name=f"Tracker {tracker_details['_id']}",
|
||||
translation_key="tracker",
|
||||
translation_placeholders={"id": tracker_details["_id"]},
|
||||
manufacturer="Tractive GmbH",
|
||||
sw_version=tracker_details["fw_version"],
|
||||
model_id=tracker_details["model_number"],
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"tracker": {
|
||||
"name": "Tracker {id}"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"tracker_power_saving": {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["tuya_sharing"],
|
||||
"requirements": [
|
||||
"tuya-device-handlers==0.0.21",
|
||||
"tuya-device-handlers==0.0.22",
|
||||
"tuya-device-sharing-sdk==0.2.8"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -75,6 +75,10 @@ class VolvoLock(VolvoEntity, LockEntity):
|
||||
|
||||
def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None:
|
||||
"""Update the state of the entity."""
|
||||
if api_field is None:
|
||||
self._attr_is_locked = None
|
||||
return
|
||||
|
||||
assert isinstance(api_field, VolvoCarsValue)
|
||||
self._attr_is_locked = api_field.value == "LOCKED"
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["wiim.sdk", "async_upnp_client"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["wiim==0.1.2"],
|
||||
"requirements": ["wiim==0.1.4"],
|
||||
"zeroconf": ["_linkplay._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -349,15 +349,12 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
|
||||
sdk_status_str,
|
||||
)
|
||||
else:
|
||||
self._device.playing_status = sdk_status
|
||||
if sdk_status == SDKPlayingStatus.STOPPED:
|
||||
LOGGER.debug(
|
||||
"Device %s: TransportState is STOPPED."
|
||||
" Resetting media position and metadata",
|
||||
self.entity_id,
|
||||
)
|
||||
self._device.current_position = 0
|
||||
self._device.current_track_duration = 0
|
||||
self._attr_media_position_updated_at = None
|
||||
self._attr_media_duration = None
|
||||
self._attr_media_position = None
|
||||
|
||||
@@ -112,3 +112,8 @@ class WLEDUpdateEntity(WLEDEntity, UpdateEntity):
|
||||
version = cast(str, self.latest_version)
|
||||
await self.coordinator.wled.upgrade(version=version)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity."""
|
||||
await super().async_update()
|
||||
await self.releases_coordinator.async_request_refresh()
|
||||
|
||||
@@ -468,7 +468,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
|
||||
|
||||
async def on_restart(self) -> None:
|
||||
"""Block until pipeline loop will be restarted."""
|
||||
_LOGGER.warning(
|
||||
_LOGGER.debug(
|
||||
"Satellite has been disconnected. Reconnecting in %s second(s)",
|
||||
_RECONNECT_SECONDS,
|
||||
)
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["socketio", "engineio", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
|
||||
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ class MusicCastDeviceEntity(MusicCastEntity):
|
||||
},
|
||||
manufacturer=BRAND,
|
||||
model=self.coordinator.data.model_name,
|
||||
sw_version=self.coordinator.data.system_version,
|
||||
sw_version=str(self.coordinator.data.system_version),
|
||||
)
|
||||
|
||||
if self._zone_id == DEFAULT_ZONE:
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"name": "Rain delay"
|
||||
},
|
||||
"water_hammer_duration": {
|
||||
"name": "Water hammer reduction"
|
||||
"name": "Water hammer duration"
|
||||
},
|
||||
"zone_delay": {
|
||||
"name": "Zone delay"
|
||||
|
||||
@@ -6,8 +6,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
LocalOAuth2ImplementationWithPkce,
|
||||
)
|
||||
|
||||
from .const import YOTO_AUDIENCE, YOTO_SCOPES
|
||||
|
||||
AUTHORIZE_URL = "https://login.yotoplay.com/authorize"
|
||||
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
|
||||
|
||||
@@ -16,9 +14,9 @@ async def async_get_auth_implementation(
|
||||
hass: HomeAssistant,
|
||||
auth_domain: str,
|
||||
credential: ClientCredential,
|
||||
) -> YotoOAuth2Implementation:
|
||||
"""Return a Yoto OAuth2 implementation backed by the user's credential."""
|
||||
return YotoOAuth2Implementation(
|
||||
) -> LocalOAuth2ImplementationWithPkce:
|
||||
"""Return a Yoto OAuth2 implementation with PKCE."""
|
||||
return LocalOAuth2ImplementationWithPkce(
|
||||
hass,
|
||||
auth_domain,
|
||||
credential.client_id,
|
||||
@@ -26,15 +24,3 @@ async def async_get_auth_implementation(
|
||||
TOKEN_URL,
|
||||
credential.client_secret,
|
||||
)
|
||||
|
||||
|
||||
class YotoOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
|
||||
"""Yoto OAuth2 implementation with PKCE, audience and scopes."""
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Append Yoto's audience and scopes to every authorize URL."""
|
||||
return super().extra_authorize_data | {
|
||||
"audience": YOTO_AUDIENCE,
|
||||
"scope": " ".join(YOTO_SCOPES),
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user