mirror of
https://github.com/home-assistant/core.git
synced 2026-06-03 18:03:43 +02:00
Compare commits
123 Commits
dev
...
2026.6.0b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d0565f007 | |||
| 083af9ccc7 | |||
| 6c87284dee | |||
| 0e0b29d16e | |||
| 8e493d84f1 | |||
| 4e2bc610e3 | |||
| 82d83feda4 | |||
| 265fe6d338 | |||
| bb8036f2c8 | |||
| 387b84ec7b | |||
| 24037fcfa3 | |||
| 994b210588 | |||
| db6f1426ec | |||
| 8ce5ba2ba4 | |||
| b176fb2113 | |||
| ada8a98f87 | |||
| 763d9879bf | |||
| 7bbd0ea472 | |||
| 60f458a372 | |||
| 05eada2569 | |||
| d2abd7f6ca | |||
| af08e5e7d0 | |||
| b03d87dc21 | |||
| d8a9ea1d9d | |||
| 5ff07fcc49 | |||
| 6f59bb0661 | |||
| c82d32bbae | |||
| 4fbc363965 | |||
| 8622f0f4de | |||
| b49a6b89b6 | |||
| 0bfd4c44bb | |||
| c09216650f | |||
| 6057d32636 | |||
| 51c9d0c6e5 | |||
| 323304664e | |||
| 3dda7d9848 | |||
| 5e56d74257 | |||
| e5f9c7892a | |||
| a0d713a4a7 | |||
| 84f4f876b1 | |||
| 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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from bleak.backends.device import BLEDevice
|
||||
from bleak_retry_connector import close_stale_connections_by_address
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -63,7 +64,16 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
||||
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find Airthings device with address {address}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
self.hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
self.ble_device = ble_device
|
||||
|
||||
|
||||
@@ -54,5 +54,10 @@
|
||||
"name": "Radon longterm level"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find Airthings device with address {address}: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -204,7 +204,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
|
||||
async def sync_media_state(self) -> None:
|
||||
"""Sync media state."""
|
||||
await self.api.sync_media_state()
|
||||
try:
|
||||
await self.api.sync_media_state()
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotConnect, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotRetrieveData, ValueError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
async def media_state_event_handler(
|
||||
self, media_state: dict[str, AmazonMediaState]
|
||||
|
||||
@@ -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==14.0.0"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Media player platform for Alexa Devices."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
from aioamazondevices.structures import (
|
||||
AmazonMediaControls,
|
||||
@@ -38,18 +37,6 @@ STANDARD_SUPPORTED_FEATURES = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
|
||||
"""Describes an Alexa Devices media player entity."""
|
||||
|
||||
|
||||
MEDIA_PLAYERS: Final = (
|
||||
AmazonDevicesMediaPlayerEntityDescription(
|
||||
key="media",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
@@ -69,9 +56,10 @@ async def async_setup_entry(
|
||||
continue
|
||||
|
||||
known_devices.add(serial_num)
|
||||
new_entities.extend(
|
||||
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
|
||||
for description in MEDIA_PLAYERS
|
||||
new_entities.append(
|
||||
AlexaDevicesMediaPlayer(
|
||||
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
|
||||
)
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
@@ -85,8 +73,6 @@ async def async_setup_entry(
|
||||
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
"""Representation of an Alexa device media player."""
|
||||
|
||||
entity_description: AmazonDevicesMediaPlayerEntityDescription
|
||||
|
||||
_attr_name = None # Uses the device name
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_volume_step = 0.05
|
||||
@@ -95,7 +81,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
self,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
serial_num: str,
|
||||
description: AmazonDevicesMediaPlayerEntityDescription,
|
||||
description: MediaPlayerEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._prev_volume: int | None = None
|
||||
@@ -156,9 +142,11 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return True if the volume is muted."""
|
||||
if not self.volume_state:
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
return None
|
||||
return self.volume_state.volume == 0
|
||||
# is_muted is True when Alexa has muted the device
|
||||
# volume == 0 is where we have muted by setting volume to 0
|
||||
return self.volume_state.is_muted or self.volume_state.volume == 0
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
@@ -212,7 +200,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | None:
|
||||
"""Content type — tells HA what kind of media is playing."""
|
||||
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
|
||||
if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED):
|
||||
return MediaType.MUSIC
|
||||
return None
|
||||
|
||||
@@ -225,7 +213,8 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
await self.async_call_alexa_music(media_id, media_type)
|
||||
provider = media_type.value if isinstance(media_type, MediaType) else media_type
|
||||
await self.async_call_alexa_music(media_id, provider)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_call_alexa_music(
|
||||
@@ -259,12 +248,20 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
return
|
||||
if mute:
|
||||
self._prev_volume = self.volume_state.volume
|
||||
target_volume = 0
|
||||
else:
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
await self.async_set_volume_level(0)
|
||||
return
|
||||
|
||||
if self.volume_state.is_muted and self._prev_volume is None:
|
||||
# is muted by Alexa which we can see but not control
|
||||
# when muted this way, volume is still set
|
||||
# changing volume will unmute
|
||||
# if HA set volume to 0 then Alexa muted we just default to 30%
|
||||
self._prev_volume = self.volume_state.volume or 30
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
await self.async_set_volume_level(target_volume / 100)
|
||||
self._prev_volume = None
|
||||
|
||||
@alexa_api_call
|
||||
async def _send_media_command(self, command: AmazonMediaControls) -> None:
|
||||
|
||||
@@ -125,6 +125,9 @@
|
||||
},
|
||||
"invalid_sound_value": {
|
||||
"message": "Invalid sound {sound} specified"
|
||||
},
|
||||
"unknown_exception": {
|
||||
"message": "Unknown error occurred: {error}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -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.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -12,13 +12,19 @@ from homeassistant.const import (
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_EVENT,
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.trigger import (
|
||||
TriggerActionType,
|
||||
TriggerInfo,
|
||||
# protected, but only used for legacy triggers
|
||||
_async_attach_trigger_cls,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -79,16 +85,18 @@ async def async_attach_trigger(
|
||||
event = zone.EVENT_ENTER
|
||||
else:
|
||||
event = zone.EVENT_LEAVE
|
||||
|
||||
zone_config = {
|
||||
CONF_PLATFORM: ZONE_DOMAIN,
|
||||
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
|
||||
CONF_ZONE: config[CONF_ZONE],
|
||||
CONF_EVENT: event,
|
||||
}
|
||||
zone_config = await zone.async_validate_trigger_config(hass, zone_config)
|
||||
return await zone.async_attach_trigger(
|
||||
hass, zone_config, action, trigger_info, platform_type="device"
|
||||
zone_config = await zone.LegacyZoneTrigger.async_validate_config(
|
||||
hass,
|
||||
{
|
||||
CONF_OPTIONS: {
|
||||
CONF_ENTITY_ID: [config[CONF_ENTITY_ID]],
|
||||
CONF_ZONE: config[CONF_ZONE],
|
||||
CONF_EVENT: event,
|
||||
}
|
||||
},
|
||||
)
|
||||
return await _async_attach_trigger_cls(
|
||||
hass, zone.LegacyZoneTrigger, "device", zone_config, action, trigger_info
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,13 +8,14 @@ from eq3btsmart import Thermostat
|
||||
from eq3btsmart.exceptions import Eq3Exception
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
|
||||
from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
|
||||
from .models import Eq3Config, Eq3ConfigEntryData
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -49,7 +50,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
|
||||
|
||||
if device is None:
|
||||
raise ConfigEntryNotReady(
|
||||
f"[{eq3_config.mac_address}] Device could not be found"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"mac_address": eq3_config.mac_address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
mac_address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
thermostat = Thermostat(device)
|
||||
|
||||
@@ -61,5 +61,10 @@
|
||||
"name": "Lock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "[{mac_address}] Device could not be found: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,14 +23,18 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN
|
||||
from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN, USER_AGENT
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_fetch_feed(hass: HomeAssistant, url: str) -> feedparser.FeedParserDict:
|
||||
"""Fetch the feed."""
|
||||
return await hass.async_add_executor_job(feedparser.parse, url)
|
||||
|
||||
def _parse_feed() -> feedparser.FeedParserDict:
|
||||
return feedparser.parse(url, agent=USER_AGENT)
|
||||
|
||||
return await hass.async_add_executor_job(_parse_feed)
|
||||
|
||||
|
||||
class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import APPLICATION_NAME, __version__ as ha_version
|
||||
|
||||
DOMAIN: Final[str] = "feedreader"
|
||||
|
||||
CONF_MAX_ENTRIES: Final[str] = "max_entries"
|
||||
@@ -10,3 +12,5 @@ DEFAULT_MAX_ENTRIES: Final[int] = 20
|
||||
DEFAULT_SCAN_INTERVAL: Final[timedelta] = timedelta(hours=1)
|
||||
|
||||
EVENT_FEEDREADER: Final[str] = "feedreader"
|
||||
|
||||
USER_AGENT: Final[str] = f"{APPLICATION_NAME}/{ha_version}"
|
||||
|
||||
@@ -18,7 +18,13 @@ from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER
|
||||
from .const import (
|
||||
CONF_MAX_ENTRIES,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
EVENT_FEEDREADER,
|
||||
USER_AGENT,
|
||||
)
|
||||
|
||||
DELAY_SAVE = 30
|
||||
STORAGE_VERSION = 1
|
||||
@@ -74,6 +80,7 @@ class FeedReaderCoordinator(
|
||||
self.url,
|
||||
etag=None if not self._feed else self._feed.get("etag"),
|
||||
modified=None if not self._feed else self._feed.get("modified"),
|
||||
agent=USER_AGENT,
|
||||
)
|
||||
|
||||
feed = await self.hass.async_add_executor_job(_parse_feed)
|
||||
|
||||
@@ -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.3"]
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
@fs_command_exception_wrap
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
if (await self.fs_device.get_play_state()) == PlayState.STOPPED:
|
||||
if (await self.fs_device.get_play_status()) == PlayState.STOPPED:
|
||||
# The 'play' command only seems to work when the current stream is paused.
|
||||
# We need to send a 'stop' command instead to resume a stopped stream.
|
||||
await self.fs_device.stop()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ from bleak import BleakError
|
||||
from bleak_retry_connector import close_stale_connections_by_address, get_device
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -56,7 +57,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) ->
|
||||
)
|
||||
except (TimeoutError, BleakError) as exception:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to device {address} due to {exception}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_failed",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"error": str(exception) or type(exception).__name__,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
) from exception
|
||||
|
||||
LOGGER.debug("connected and paired")
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_failed": {
|
||||
"message": "Unable to connect to device {address} due to {error}: {reason}"
|
||||
},
|
||||
"pin_required": {
|
||||
"message": "PIN is required for {domain_name}"
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
|
||||
await hass.async_add_executor_job(account.setup)
|
||||
|
||||
entry.runtime_data = account
|
||||
entry.async_on_unload(account.cancel_fetch)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ class IcloudAccount:
|
||||
self._retried_fetch = False
|
||||
self._config_entry = config_entry
|
||||
|
||||
self._unsub_fetch: CALLBACK_TYPE | None = None
|
||||
self.listeners: list[CALLBACK_TYPE] = []
|
||||
|
||||
def setup(self) -> None:
|
||||
@@ -293,9 +294,16 @@ class IcloudAccount:
|
||||
self._max_interval,
|
||||
)
|
||||
|
||||
def cancel_fetch(self) -> None:
|
||||
"""Cancel the scheduled fetch timer."""
|
||||
if self._unsub_fetch is not None:
|
||||
self._unsub_fetch()
|
||||
self._unsub_fetch = None
|
||||
|
||||
def _schedule_next_fetch(self) -> None:
|
||||
self.cancel_fetch()
|
||||
if not self._config_entry.pref_disable_polling:
|
||||
track_point_in_utc_time(
|
||||
self._unsub_fetch = track_point_in_utc_time(
|
||||
self.hass,
|
||||
self.keep_alive,
|
||||
utcnow() + timedelta(minutes=self._fetch_interval),
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Home Assistant integration for indevolt device."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -20,6 +23,28 @@ PLATFORMS: list[Platform] = [
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
# 1.1 -> 1.2: indevolt-api 1.8.3 changed IndevoltBattery.MAIN_HEATING_STATE
|
||||
# from 9079 to 9080, so migrate affected unique IDs.
|
||||
@callback
|
||||
def migrate_unique_id(
|
||||
entity_entry: er.RegistryEntry,
|
||||
) -> dict[str, Any] | None:
|
||||
if entity_entry.unique_id.endswith("_9079"):
|
||||
return {
|
||||
"new_unique_id": entity_entry.unique_id.removesuffix("_9079")
|
||||
+ "_9080"
|
||||
}
|
||||
return None
|
||||
|
||||
await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id)
|
||||
hass.config_entries.async_update_entry(entry, version=1, minor_version=2)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool:
|
||||
"""Set up indevolt integration entry using given configuration."""
|
||||
coordinator = IndevoltCoordinator(hass, entry)
|
||||
|
||||
@@ -25,8 +25,8 @@ PARALLEL_UPDATES = 0
|
||||
class IndevoltBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Custom entity description class for Indevolt binary sensors."""
|
||||
|
||||
on_value: int = 1
|
||||
off_value: int = 0
|
||||
on_value: int = 1000
|
||||
off_value: int = 1001
|
||||
generation: tuple[int, ...] = (1, 2)
|
||||
|
||||
|
||||
@@ -35,8 +35,6 @@ BINARY_SENSORS: Final = (
|
||||
IndevoltBinarySensorEntityDescription(
|
||||
key=IndevoltGrid.METER_CONNECTED,
|
||||
translation_key="meter_connected",
|
||||
on_value=1000,
|
||||
off_value=1001,
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
|
||||
@@ -22,6 +22,7 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Configuration flow for Indevolt integration."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["indevolt-api==1.8.2"],
|
||||
"requirements": ["indevolt-api==1.8.3"],
|
||||
"zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }]
|
||||
}
|
||||
|
||||
@@ -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,9 +7,11 @@ from typing import Any
|
||||
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothReachabilityIntent,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfo,
|
||||
BluetoothServiceInfoBleak,
|
||||
async_address_reachability_diagnostics,
|
||||
async_ble_device_from_address,
|
||||
async_last_service_info,
|
||||
)
|
||||
@@ -84,7 +86,14 @@ class INKBIRDActiveBluetoothProcessorCoordinator(
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_advertisement",
|
||||
translation_placeholders={"address": self.address},
|
||||
translation_placeholders={
|
||||
"address": self.address,
|
||||
"reason": async_address_reachability_diagnostics(
|
||||
self.hass,
|
||||
self.address.upper(),
|
||||
BluetoothReachabilityIntent.ACTIVE_ADVERTISEMENT,
|
||||
),
|
||||
},
|
||||
)
|
||||
await self._data.async_start(service_info, service_info.device)
|
||||
self._entry.async_on_unload(self._data.async_stop)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"no_advertisement": {
|
||||
"message": "The device with address {address} is not advertising; Make sure it is in range and powered on."
|
||||
"message": "The device with address {address} is not advertising: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.15.0",
|
||||
"xknxproject==3.9.0",
|
||||
"knx-frontend==2026.4.30.60856"
|
||||
"knx-frontend==2026.6.1.213802"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ from bleak_retry_connector import (
|
||||
from ld2410_ble import LD2410BLE
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
|
||||
from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LD2410BLECoordinator
|
||||
from .models import LD2410BLEConfigEntry, LD2410BLEData
|
||||
|
||||
@@ -34,7 +36,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LD2410BLEConfigEntry) ->
|
||||
) or await get_device(address)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find LD2410B device with address {address}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
ld2410_ble = LD2410BLE(ble_device)
|
||||
|
||||
@@ -97,5 +97,10 @@
|
||||
"name": "Static target energy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find LD2410B device with address {address}: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import asyncio
|
||||
from led_ble import LEDBLE
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
|
||||
from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DEVICE_TIMEOUT
|
||||
from .const import DEVICE_TIMEOUT, DOMAIN
|
||||
from .coordinator import LEDBLEConfigEntry, LEDBLECoordinator, LEDBLEData
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
@@ -22,7 +23,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bo
|
||||
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find LED BLE device with address {address}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
led_ble = LEDBLE(ble_device)
|
||||
|
||||
@@ -18,5 +18,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find LED BLE device with address {address}: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -23,6 +23,7 @@ from matter_ble_proxy import (
|
||||
)
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
MONOTONIC_TIME,
|
||||
BluetoothScanningMode,
|
||||
async_ble_device_from_address,
|
||||
async_register_callback,
|
||||
@@ -51,11 +52,18 @@ class HaBluetoothScanSource(BleScanSource):
|
||||
if self._cancel is not None:
|
||||
return
|
||||
|
||||
# Drop HA's synchronous replay of stale history on register; otherwise a
|
||||
# rotating peripheral's old addresses each become a parallel connect candidate.
|
||||
# `MONOTONIC_TIME` is the clock that stamps `service_info.time`.
|
||||
scan_start = MONOTONIC_TIME()
|
||||
|
||||
@callback
|
||||
def _on_advertisement(
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
_change: object,
|
||||
) -> None:
|
||||
if service_info.time < scan_start:
|
||||
return
|
||||
try:
|
||||
callback_fn(_to_advertisement_data(service_info))
|
||||
except Exception:
|
||||
@@ -108,5 +116,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
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.const import CONF_TOKEN, CONF_USERNAME
|
||||
|
||||
from .const import AUTH_OTHER, CONF_AUTH_METHOD, CONF_REALM, CONF_TOKEN_ID
|
||||
|
||||
@@ -21,7 +21,7 @@ def sanitize_config_entry(input_data: Mapping[str, Any]) -> dict[str, Any]:
|
||||
data[CONF_REALM] = realm
|
||||
data[CONF_USERNAME] = f"{username}@{realm}"
|
||||
|
||||
if CONF_TOKEN_ID in data and "!" in data[CONF_TOKEN_ID]:
|
||||
if data.get(CONF_TOKEN) and data.get(CONF_TOKEN_ID) and "!" in data[CONF_TOKEN_ID]:
|
||||
data[CONF_TOKEN_ID] = data[CONF_TOKEN_ID].split("!")[1]
|
||||
|
||||
return data
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
||||
from httpx import HTTPError, InvalidURL
|
||||
@@ -41,7 +41,8 @@ def ensure_printer_is_supported(version: VersionInfo) -> None:
|
||||
|
||||
# Workaround to allow PrusaLink 0.7.2 on MK3 and MK2.5 that supports
|
||||
# the 2.0.0 API, but doesn't advertise it yet
|
||||
original = cast(str, version.get("original", ""))
|
||||
original_value = version.get("original")
|
||||
original = original_value if isinstance(original_value, str) else ""
|
||||
if original.startswith(("PrusaLink I3MK3", "PrusaLink I3MK2")) and (
|
||||
AwesomeVersion("0.7.2") <= AwesomeVersion(version["server"])
|
||||
):
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user