mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
Merge branch 'dev' into frenck/add-codspeed-benchmarks
This commit is contained in:
@@ -675,7 +675,7 @@ class AuthManager:
|
||||
jwt_wrapper.verify_and_decode(
|
||||
token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"]
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
except jwt.InvalidTokenError, jwt.InvalidKeyError:
|
||||
return None
|
||||
|
||||
if refresh_token is None or not refresh_token.user.is_active:
|
||||
|
||||
@@ -94,7 +94,7 @@ from .const import (
|
||||
)
|
||||
from .manager import HomeAssistantBluetoothManager
|
||||
from .match import BluetoothCallbackMatcher, IntegrationMatcher
|
||||
from .models import BluetoothCallback, BluetoothChange
|
||||
from .models import BluetoothCallback, BluetoothCallbackReplay, BluetoothChange
|
||||
from .storage import BluetoothStorage
|
||||
from .util import adapter_title, resolve_scanning_mode
|
||||
|
||||
@@ -109,6 +109,7 @@ __all__ = [
|
||||
"BaseHaScanner",
|
||||
"BluetoothCallback",
|
||||
"BluetoothCallbackMatcher",
|
||||
"BluetoothCallbackReplay",
|
||||
"BluetoothChange",
|
||||
"BluetoothReachabilityIntent",
|
||||
"BluetoothScannerDevice",
|
||||
|
||||
@@ -26,7 +26,12 @@ from homeassistant.helpers.singleton import singleton
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import HomeAssistantBluetoothManager
|
||||
from .match import BluetoothCallbackMatcher
|
||||
from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback
|
||||
from .models import (
|
||||
BluetoothCallback,
|
||||
BluetoothCallbackReplay,
|
||||
BluetoothChange,
|
||||
ProcessAdvertisementCallback,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak.backends.device import BLEDevice
|
||||
@@ -143,6 +148,7 @@ def async_register_callback(
|
||||
*,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
replay: BluetoothCallbackReplay = BluetoothCallbackReplay.OLDEST_FIRST,
|
||||
) -> Callable[[], None]:
|
||||
"""Register to receive a callback on bluetooth change.
|
||||
|
||||
@@ -155,10 +161,13 @@ def async_register_callback(
|
||||
values. Without an address in the matcher the active-scan request
|
||||
is skipped; the callback itself still fires normally.
|
||||
|
||||
``replay`` controls which cached advertisements are replayed to the
|
||||
callback on registration; defaults to OLDEST_FIRST.
|
||||
|
||||
Returns a callback that can be used to cancel the registration.
|
||||
"""
|
||||
return _get_manager(hass).async_register_callback(
|
||||
callback, match_dict, mode, scan_interval, scan_duration
|
||||
callback, match_dict, mode, scan_interval, scan_duration, replay
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""The bluetooth integration."""
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
import itertools
|
||||
import logging
|
||||
from operator import attrgetter
|
||||
from typing import override
|
||||
|
||||
from bleak_retry_connector import BleakSlotManager
|
||||
@@ -54,7 +55,12 @@ from .match import (
|
||||
IntegrationMatcher,
|
||||
ble_device_matches,
|
||||
)
|
||||
from .models import BluetoothCallback, BluetoothChange, BluetoothServiceInfoBleak
|
||||
from .models import (
|
||||
BluetoothCallback,
|
||||
BluetoothCallbackReplay,
|
||||
BluetoothChange,
|
||||
BluetoothServiceInfoBleak,
|
||||
)
|
||||
from .storage import BluetoothStorage
|
||||
from .util import async_load_history_from_system
|
||||
|
||||
@@ -212,6 +218,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
mode: BluetoothScanningMode = BluetoothScanningMode.ACTIVE,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
replay: BluetoothCallbackReplay = BluetoothCallbackReplay.OLDEST_FIRST,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback."""
|
||||
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
|
||||
@@ -245,16 +252,37 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
if cancel_active_scan is not None:
|
||||
cancel_active_scan()
|
||||
|
||||
if replay is not BluetoothCallbackReplay.DISABLED:
|
||||
self._async_replay_history_for_callback(
|
||||
connectable, callback, callback_matcher, replay
|
||||
)
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
def _async_replay_history_for_callback(
|
||||
self,
|
||||
connectable: bool,
|
||||
callback: BluetoothCallback,
|
||||
callback_matcher: BluetoothCallbackMatcherWithCallback,
|
||||
replay: BluetoothCallbackReplay,
|
||||
) -> None:
|
||||
"""Replay history for a callback."""
|
||||
# If we have history for the subscriber, we can trigger the callback
|
||||
# immediately with the last packet so the subscriber can see the
|
||||
# device.
|
||||
history = self._connectable_history if connectable else self._all_history
|
||||
service_infos: Iterable[BluetoothServiceInfoBleak] = []
|
||||
service_infos: list[BluetoothServiceInfoBleak] = []
|
||||
if (address := callback_matcher.get(ADDRESS)) is not None:
|
||||
if service_info := history.get(address):
|
||||
service_infos = [service_info]
|
||||
else:
|
||||
service_infos = history.values()
|
||||
# Sort by time explicitly; dict insertion order is not guaranteed
|
||||
# to match advertisement time after history is loaded from storage.
|
||||
service_infos = sorted(
|
||||
history.values(),
|
||||
key=attrgetter("time"),
|
||||
reverse=replay is BluetoothCallbackReplay.NEWEST_FIRST,
|
||||
)
|
||||
|
||||
for service_info in service_infos:
|
||||
if ble_device_matches(callback_matcher, service_info):
|
||||
@@ -263,8 +291,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
except Exception:
|
||||
_LOGGER.exception("Error in bluetooth callback")
|
||||
|
||||
return _async_remove_callback
|
||||
|
||||
@hass_callback
|
||||
@override
|
||||
def async_stop(self, event: Event | None = None) -> None:
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"""Models for bluetooth."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from enum import Enum, auto
|
||||
|
||||
from home_assistant_bluetooth import BluetoothServiceInfoBleak
|
||||
|
||||
BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT")
|
||||
type BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None]
|
||||
type ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool]
|
||||
|
||||
|
||||
class BluetoothCallbackReplay(Enum):
|
||||
"""Controls how history is replayed when a callback is registered."""
|
||||
|
||||
OLDEST_FIRST = auto()
|
||||
NEWEST_FIRST = auto()
|
||||
DISABLED = auto()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Data update coordinator for caldav."""
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from functools import partial
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, override
|
||||
@@ -54,15 +53,17 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
) -> list[CalendarEvent]:
|
||||
"""Get all events in a specific time frame."""
|
||||
# Get event list from the current calendar
|
||||
vevent_list = await hass.async_add_executor_job(
|
||||
partial(
|
||||
self.calendar.search,
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
event=True,
|
||||
expand=True,
|
||||
)
|
||||
return await hass.async_add_executor_job(self._get_events, start_date, end_date)
|
||||
|
||||
def _get_events(
|
||||
self, start_date: datetime, end_date: datetime
|
||||
) -> list[CalendarEvent]:
|
||||
"""Fetch and parse events in a specific time frame."""
|
||||
vevent_list = self.calendar.search(
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
event=True,
|
||||
expand=True,
|
||||
)
|
||||
event_list = []
|
||||
for event in vevent_list:
|
||||
@@ -96,16 +97,23 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
||||
start_of_today = dt_util.start_of_local_day()
|
||||
start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days)
|
||||
|
||||
event, offset = await self.hass.async_add_executor_job(
|
||||
self._get_next_event, start_of_today, start_of_tomorrow
|
||||
)
|
||||
self.offset = offset
|
||||
return event
|
||||
|
||||
def _get_next_event(
|
||||
self, start_of_today: datetime, start_of_tomorrow: datetime
|
||||
) -> tuple[CalendarEvent | None, timedelta | None]:
|
||||
"""Fetch and parse the next matching event."""
|
||||
# We have to retrieve the results for the whole day as the server
|
||||
# won't return events that have already started
|
||||
results = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
self.calendar.search,
|
||||
start=start_of_today,
|
||||
end=start_of_tomorrow,
|
||||
event=True,
|
||||
expand=True,
|
||||
),
|
||||
results = self.calendar.search(
|
||||
start=start_of_today,
|
||||
end=start_of_tomorrow,
|
||||
event=True,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
# Create new events for each recurrence of an event that happens today.
|
||||
@@ -168,15 +176,13 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
||||
len(vevents),
|
||||
self.calendar.name,
|
||||
)
|
||||
self.offset = None
|
||||
return None
|
||||
return None, None
|
||||
|
||||
# Populate the entity attributes with the event values
|
||||
(summary, offset) = extract_offset(
|
||||
get_attr_value(vevent, "summary") or "", OFFSET
|
||||
)
|
||||
self.offset = offset
|
||||
return CalendarEvent(
|
||||
next_event = CalendarEvent(
|
||||
summary=summary,
|
||||
start=self.to_local(vevent.dtstart.value),
|
||||
end=self.to_local(self.get_end_date(vevent)),
|
||||
@@ -189,6 +195,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
||||
else None
|
||||
),
|
||||
)
|
||||
return next_event, offset
|
||||
|
||||
@staticmethod
|
||||
def is_matching(vevent, search):
|
||||
|
||||
@@ -60,6 +60,16 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
def _get_todo_items(calendar: caldav.Calendar) -> list[TodoItem]:
|
||||
"""Fetch and parse todo items."""
|
||||
results = calendar.search(todo=True, include_completed=True)
|
||||
return [
|
||||
todo_item
|
||||
for resource in results
|
||||
if (todo_item := _todo_item(resource)) is not None
|
||||
]
|
||||
|
||||
|
||||
def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
|
||||
"""Convert a caldav Todo into a TodoItem."""
|
||||
if (
|
||||
@@ -108,18 +118,9 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update To-do list entity state."""
|
||||
results = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
self._calendar.search,
|
||||
todo=True,
|
||||
include_completed=True,
|
||||
)
|
||||
self._attr_todo_items = await self.hass.async_add_executor_job(
|
||||
_get_todo_items, self._calendar
|
||||
)
|
||||
self._attr_todo_items = [
|
||||
todo_item
|
||||
for resource in results
|
||||
if (todo_item := _todo_item(resource)) is not None
|
||||
]
|
||||
|
||||
@override
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Media player platform for the Denon RS-232 integration."""
|
||||
|
||||
from typing import Literal, cast, override
|
||||
import re
|
||||
from typing import Any, Literal, cast, override
|
||||
|
||||
from denon_rs232 import (
|
||||
MIN_VOLUME_DB,
|
||||
@@ -13,13 +14,17 @@ from denon_rs232 import (
|
||||
)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseError,
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -71,6 +76,25 @@ INPUT_SOURCE_DENON_TO_HA: dict[InputSource, str] = {
|
||||
InputSource.DAB: "dab",
|
||||
}
|
||||
|
||||
TUNER_PRESETS_ROOT = "presets"
|
||||
TUNER_FREQUENCY_MIN = 8750
|
||||
TUNER_FREQUENCY_MAX = 10800
|
||||
TUNER_FREQUENCY_LENGTH = 6
|
||||
#: Reported frequencies at or above this value are AM, which is not supported.
|
||||
TUNER_FREQUENCY_FM_MAX = 50000
|
||||
|
||||
|
||||
def _tuner_frequency_to_mhz(frequency: str | None) -> str | None:
|
||||
"""Convert a reported tuner frequency to MHz, or None if it is not FM."""
|
||||
if frequency is None or not frequency.isdigit():
|
||||
return None
|
||||
|
||||
value = int(frequency)
|
||||
if value >= TUNER_FREQUENCY_FM_MAX:
|
||||
return None
|
||||
|
||||
return f"{value / 100:.2f}"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -138,7 +162,11 @@ class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
|
||||
if zone == "main":
|
||||
self._attr_name = None
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
self._attr_supported_features |= (
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
)
|
||||
else:
|
||||
self._attr_name = "Zone 2" if zone == "zone_2" else "Zone 3"
|
||||
|
||||
@@ -172,6 +200,13 @@ class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
source = self._player.input_source
|
||||
self._attr_source = INPUT_SOURCE_DENON_TO_HA.get(source) if source else None
|
||||
|
||||
if source is InputSource.TUNER:
|
||||
self._attr_media_channel = _tuner_frequency_to_mhz(
|
||||
self._receiver.state.main_zone.tuner_frequency
|
||||
)
|
||||
else:
|
||||
self._attr_media_channel = None
|
||||
|
||||
volume_min = self._player.volume_min
|
||||
volume_max = self._player.volume_max
|
||||
if volume_min is not None:
|
||||
@@ -239,3 +274,62 @@ class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
raise HomeAssistantError("Invalid source")
|
||||
|
||||
await self._player.select_input_source(input_source)
|
||||
|
||||
@override
|
||||
async def async_play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Tune to a tuner preset or an FM frequency."""
|
||||
if media_type != MediaType.CHANNEL:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_media_type",
|
||||
translation_placeholders={"media_type": str(media_type)},
|
||||
)
|
||||
|
||||
player = cast(MainPlayer, self._player)
|
||||
if re.fullmatch(r"[A-G][1-8]", media_id):
|
||||
await player.set_tuner_preset(media_id)
|
||||
elif (match := re.fullmatch(r"0*([0-9]{1,5})", media_id)) and (
|
||||
TUNER_FREQUENCY_MIN <= (frequency := int(match[1])) <= TUNER_FREQUENCY_MAX
|
||||
):
|
||||
await player.set_tuner_frequency(f"{frequency:0{TUNER_FREQUENCY_LENGTH}d}")
|
||||
else:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_tuner_channel",
|
||||
translation_placeholders={"media_id": media_id},
|
||||
)
|
||||
|
||||
@override
|
||||
async def async_browse_media(
|
||||
self,
|
||||
media_content_type: MediaType | str | None = None,
|
||||
media_content_id: str | None = None,
|
||||
) -> BrowseMedia:
|
||||
"""List the tuner presets as playable channels."""
|
||||
if media_content_id not in (None, TUNER_PRESETS_ROOT):
|
||||
raise BrowseError(f"Media not found: {media_content_id}")
|
||||
|
||||
return BrowseMedia(
|
||||
title="Tuner presets",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id=TUNER_PRESETS_ROOT,
|
||||
media_content_type=MediaType.CHANNELS,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.CHANNEL,
|
||||
children=[
|
||||
BrowseMedia(
|
||||
title=preset,
|
||||
media_class=MediaClass.CHANNEL,
|
||||
media_content_id=preset,
|
||||
media_content_type=MediaType.CHANNEL,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
)
|
||||
for preset in (
|
||||
f"{bank}{number}" for bank in "ABCDEFG" for number in range(1, 9)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_tuner_channel": {
|
||||
"message": "{media_id} is not a valid tuner preset (A1-G8) or FM frequency in hundredths of MHz (8750-10800; for example, 9930 for 99.30 MHz)."
|
||||
},
|
||||
"unsupported_media_type": {
|
||||
"message": "Cannot play media of type {media_type}. Only tuner channels are supported."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"model": {
|
||||
"options": {
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -71,7 +71,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, config_entry.options[CONF_SOURCE]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -10,8 +10,15 @@ from duco_connectivity.exceptions import (
|
||||
DucoConnectionError,
|
||||
DucoError,
|
||||
DucoResponseError,
|
||||
DucoUnsupportedCapabilityError,
|
||||
)
|
||||
from duco_connectivity.models import (
|
||||
BoardInfo,
|
||||
Node,
|
||||
NodeListActionItemList,
|
||||
NodeName,
|
||||
VentilationTemperatureInfo,
|
||||
)
|
||||
from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList, NodeName
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -26,7 +33,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
type DucoConfigEntry = ConfigEntry[DucoCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class DucoData:
|
||||
"""Data returned by the Duco coordinator."""
|
||||
|
||||
@@ -34,6 +41,7 @@ class DucoData:
|
||||
node_actions: NodeListActionItemList
|
||||
rssi_wifi: int | None
|
||||
time_filter_remain: int | None
|
||||
ventilation_temperatures: VentilationTemperatureInfo | None
|
||||
|
||||
|
||||
class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
@@ -42,6 +50,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
config_entry: DucoConfigEntry
|
||||
board_info: BoardInfo
|
||||
_supports_time_filter_remain: bool
|
||||
_supports_ventilation_temperatures: bool
|
||||
_configured_node_names: dict[int, str]
|
||||
|
||||
def __init__(
|
||||
@@ -61,6 +70,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
self.client = client
|
||||
self._configured_node_names = {}
|
||||
self._supports_time_filter_remain = True
|
||||
self._supports_ventilation_temperatures = True
|
||||
|
||||
async def _async_load_node_names(self) -> None:
|
||||
"""Load configured Duco node names during setup."""
|
||||
@@ -175,9 +185,26 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
time_filter_remain = await self.client.async_get_time_filter_remaining()
|
||||
self._supports_time_filter_remain = time_filter_remain is not None
|
||||
|
||||
ventilation_temperatures = (
|
||||
self.data.ventilation_temperatures if self.data else None
|
||||
)
|
||||
if self._supports_ventilation_temperatures:
|
||||
try:
|
||||
ventilation_temperatures = (
|
||||
await self.client.async_get_ventilation_temperature_info()
|
||||
)
|
||||
except DucoUnsupportedCapabilityError:
|
||||
ventilation_temperatures = None
|
||||
self._supports_ventilation_temperatures = False
|
||||
except DucoError as err:
|
||||
_LOGGER.debug(
|
||||
"Could not fetch Duco ventilation temperatures", exc_info=err
|
||||
)
|
||||
|
||||
return DucoData(
|
||||
nodes={node.node_id: node for node in nodes},
|
||||
node_actions=node_actions,
|
||||
rssi_wifi=rssi_wifi,
|
||||
time_filter_remain=time_filter_remain,
|
||||
ventilation_temperatures=ventilation_temperatures,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco_connectivity"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-connectivity==0.10.0"],
|
||||
"requirements": ["python-duco-connectivity==0.11.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.const import (
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfRatio,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -156,6 +157,70 @@ BOX_SENSOR_DESCRIPTIONS: tuple[DucoBoxSensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda coordinator: coordinator.data.rssi_wifi,
|
||||
),
|
||||
DucoBoxSensorEntityDescription(
|
||||
key="outdoor_air_temperature",
|
||||
translation_key="outdoor_air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
supported_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures is not None
|
||||
and coordinator.data.ventilation_temperatures.temp_oda is not None
|
||||
),
|
||||
value_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures.temp_oda
|
||||
if coordinator.data.ventilation_temperatures
|
||||
else None
|
||||
),
|
||||
),
|
||||
DucoBoxSensorEntityDescription(
|
||||
key="supply_air_temperature",
|
||||
translation_key="supply_air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
supported_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures is not None
|
||||
and coordinator.data.ventilation_temperatures.temp_sup is not None
|
||||
),
|
||||
value_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures.temp_sup
|
||||
if coordinator.data.ventilation_temperatures
|
||||
else None
|
||||
),
|
||||
),
|
||||
DucoBoxSensorEntityDescription(
|
||||
key="extract_air_temperature",
|
||||
translation_key="extract_air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
supported_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures is not None
|
||||
and coordinator.data.ventilation_temperatures.temp_eta is not None
|
||||
),
|
||||
value_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures.temp_eta
|
||||
if coordinator.data.ventilation_temperatures
|
||||
else None
|
||||
),
|
||||
),
|
||||
DucoBoxSensorEntityDescription(
|
||||
key="exhaust_air_temperature",
|
||||
translation_key="exhaust_air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
supported_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures is not None
|
||||
and coordinator.data.ventilation_temperatures.temp_eha is not None
|
||||
),
|
||||
value_fn=lambda coordinator: (
|
||||
coordinator.data.ventilation_temperatures.temp_eha
|
||||
if coordinator.data.ventilation_temperatures
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"exhaust_air_temperature": {
|
||||
"name": "Exhaust air temperature"
|
||||
},
|
||||
"extract_air_temperature": {
|
||||
"name": "Extract air temperature"
|
||||
},
|
||||
"filter_remaining": {
|
||||
"name": "Filter remaining"
|
||||
},
|
||||
@@ -82,6 +88,12 @@
|
||||
"iaq_rh": {
|
||||
"name": "Humidity air quality index"
|
||||
},
|
||||
"outdoor_air_temperature": {
|
||||
"name": "Outdoor air temperature"
|
||||
},
|
||||
"supply_air_temperature": {
|
||||
"name": "Supply air temperature"
|
||||
},
|
||||
"target_flow_level": {
|
||||
"name": "Target flow level"
|
||||
},
|
||||
|
||||
@@ -624,20 +624,38 @@ class Thermostat(ClimateEntity):
|
||||
|
||||
@override
|
||||
def set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set the fan mode. Valid values are "on" or "auto"."""
|
||||
"""Set the fan mode. Valid values are "on" or "auto".
|
||||
|
||||
Ecobee's setHold accepts a fan-only payload (HTTP 200) but does not
|
||||
actually change desiredFanMode unless heatHoldTemp/coolHoldTemp are
|
||||
included — see https://www.ecobee.com/home/developer/api/examples/ex7.shtml
|
||||
Pass the current runtime setpoints so the fan hold sticks without
|
||||
altering the temperature the thermostat is already holding.
|
||||
"""
|
||||
if fan_mode.lower() not in (FAN_ON, FAN_AUTO):
|
||||
error = "Invalid fan_mode value: Valid values are 'on' or 'auto'"
|
||||
_LOGGER.error(error)
|
||||
return
|
||||
|
||||
cool_temp = self.thermostat["runtime"]["desiredCool"] / 10.0
|
||||
heat_temp = self.thermostat["runtime"]["desiredHeat"] / 10.0
|
||||
|
||||
self.data.ecobee.set_fan_mode(
|
||||
self.thermostat_index,
|
||||
fan_mode,
|
||||
self.hold_preference(),
|
||||
holdHours=self.hold_hours(),
|
||||
coolHoldTemp=cool_temp,
|
||||
heatHoldTemp=heat_temp,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Setting fan mode to: %s", fan_mode)
|
||||
_LOGGER.debug(
|
||||
"Setting fan mode to: %s (preserving heat=%s cool=%s)",
|
||||
fan_mode,
|
||||
heat_temp,
|
||||
cool_temp,
|
||||
)
|
||||
self.update_without_throttle = True
|
||||
|
||||
def set_temp_hold(self, temp):
|
||||
"""Set temperature hold in modes other than auto.
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["paho_mqtt", "pyeconet"],
|
||||
"requirements": ["pyeconet==0.2.2"]
|
||||
"requirements": ["pyeconet==0.2.4"]
|
||||
}
|
||||
|
||||
@@ -1053,6 +1053,10 @@ class ESPHomeManager:
|
||||
self._async_cleanup()
|
||||
if device_info.name:
|
||||
reconnect_logic.name = device_info.name
|
||||
# Seed the backoff cap from the restored device_info so the first
|
||||
# reconnect after a restart already caps for a deep-sleep device,
|
||||
# before the first live connect refreshes it.
|
||||
reconnect_logic.deep_sleep = device_info.has_deep_sleep
|
||||
if (
|
||||
bluetooth_mac_address := device_info.bluetooth_mac_address
|
||||
) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.6.0",
|
||||
"aioesphomeapi==45.6.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.9.7"
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ from gardena_bluetooth.const import (
|
||||
Battery,
|
||||
EventHistory,
|
||||
FlowStatistics,
|
||||
Pump,
|
||||
Sensor,
|
||||
Spray,
|
||||
Valve,
|
||||
@@ -28,6 +29,8 @@ from homeassistant.const import (
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfPressure,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
@@ -164,6 +167,24 @@ DESCRIPTIONS = (
|
||||
char=FlowStatistics.last_reset,
|
||||
get=_get_timestamp,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Pump.tank_preassure.unique_id,
|
||||
translation_key="tank_pressure",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.MBAR,
|
||||
suggested_unit_of_measurement=UnitOfPressure.BAR,
|
||||
suggested_display_precision=2,
|
||||
char=Pump.tank_preassure,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Pump.water_temperature.unique_id,
|
||||
translation_key="water_temperature",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
char=Pump.water_temperature,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Spray.current_distance.unique_id,
|
||||
translation_key="spray_current_distance",
|
||||
|
||||
@@ -151,6 +151,12 @@
|
||||
},
|
||||
"spray_current_sector": {
|
||||
"name": "Current sector"
|
||||
},
|
||||
"tank_pressure": {
|
||||
"name": "Tank pressure"
|
||||
},
|
||||
"water_temperature": {
|
||||
"name": "Water temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Diagnostics support for Gatus."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import GatusConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: GatusConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"key": ep.key,
|
||||
"name": ep.name,
|
||||
"group": ep.group,
|
||||
"results": [
|
||||
{
|
||||
"success": r.success,
|
||||
"status": r.status,
|
||||
}
|
||||
for r in ep.results
|
||||
],
|
||||
}
|
||||
for ep in coordinator.data.values()
|
||||
],
|
||||
}
|
||||
@@ -49,7 +49,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration does not support discovery.
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -154,7 +154,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, options[CONF_HUMIDIFIER]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
|
||||
from .const import CONF_DUR_COOLDOWN, CONF_HEATER, CONF_MIN_DUR, CONF_SENSOR, PLATFORMS
|
||||
@@ -82,7 +82,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, options[CONF_HEATER]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.3.0"]
|
||||
"requirements": ["gcal-sync==9.1.0", "oauth2client==4.1.3", "ical==14.0.1"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ from google_health_api.exceptions import (
|
||||
HealthApiForbiddenException,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
|
||||
@@ -58,6 +62,12 @@ class OAuth2FlowHandler(
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reconfiguration flow."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
@override
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
scopes = data.get(CONF_TOKEN, {}).get("scope", "").split()
|
||||
@@ -86,9 +96,13 @@ class OAuth2FlowHandler(
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(identity.health_user_id)
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
return self.async_update_reload_and_abort(reauth_entry, data=data)
|
||||
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
|
||||
if self.source == SOURCE_REAUTH:
|
||||
entry = self._get_reauth_entry()
|
||||
else:
|
||||
entry = self._get_reconfigure_entry()
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(entry, data=data)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
display_name = None
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["google-health-api==0.5.1"]
|
||||
"requirements": ["google-health-api==0.6.0"]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"wrong_account": "Wrong account: Please authenticate with the right account."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -57,7 +57,7 @@ from .schemas import (
|
||||
UNITS_SELECTOR,
|
||||
)
|
||||
|
||||
RECONFIGURE_SCHEMA = vol.Schema(
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): cv.string,
|
||||
vol.Required(CONF_DESTINATION): cv.string,
|
||||
@@ -65,14 +65,6 @@ RECONFIGURE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = RECONFIGURE_SCHEMA.extend(
|
||||
{
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=home-assistant-config-flow-name-field
|
||||
vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_LANGUAGE): LANGUAGE_SELECTOR,
|
||||
@@ -184,7 +176,7 @@ class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = await validate_input(self.hass, user_input)
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input.get(CONF_NAME, DEFAULT_NAME),
|
||||
title=DEFAULT_NAME,
|
||||
data=user_input,
|
||||
options=default_options(self.hass),
|
||||
)
|
||||
@@ -210,7 +202,7 @@ class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
RECONFIGURE_SCHEMA, self._get_reconfigure_entry().data
|
||||
CONFIG_SCHEMA, self._get_reconfigure_entry().data
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LANGUAGE,
|
||||
CONF_MODE,
|
||||
CONF_NAME,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
UnitOfTime,
|
||||
)
|
||||
@@ -74,7 +73,7 @@ async def async_setup_entry(
|
||||
api_key = config_entry.data[CONF_API_KEY]
|
||||
origin = config_entry.data[CONF_ORIGIN]
|
||||
destination = config_entry.data[CONF_DESTINATION]
|
||||
name = config_entry.data.get(CONF_NAME, DEFAULT_NAME)
|
||||
name = config_entry.title
|
||||
|
||||
client_options = ClientOptions(api_key=api_key)
|
||||
client = RoutesAsyncClient(client_options=client_options)
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"destination": "Destination",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"origin": "Origin"
|
||||
},
|
||||
"description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or a zone's friendly name (case-sensitive)"
|
||||
|
||||
@@ -21,13 +21,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_MODE,
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -88,11 +82,6 @@ def get_user_step_schema(data: Mapping[str, Any]) -> vol.Schema:
|
||||
travel_mode = TRAVEL_MODE_PUBLIC
|
||||
return vol.Schema(
|
||||
{
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=home-assistant-config-flow-name-field
|
||||
vol.Optional(
|
||||
CONF_NAME, default=data.get(CONF_NAME, DEFAULT_NAME)
|
||||
): cv.string,
|
||||
vol.Required(CONF_API_KEY, default=data.get(CONF_API_KEY)): cv.string,
|
||||
vol.Optional(
|
||||
CONF_MODE, default=data.get(CONF_MODE, TRAVEL_MODE_CAR)
|
||||
@@ -136,7 +125,6 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
if not errors:
|
||||
self._config[CONF_NAME] = user_input[CONF_NAME]
|
||||
self._config[CONF_API_KEY] = user_input[CONF_API_KEY]
|
||||
self._config[CONF_MODE] = user_input[CONF_MODE]
|
||||
return await self.async_step_origin_menu()
|
||||
@@ -237,11 +225,10 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
title=self._config[CONF_NAME],
|
||||
data=self._config,
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=self._config[CONF_NAME],
|
||||
title=DEFAULT_NAME,
|
||||
data=self._config,
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
@@ -283,7 +270,7 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._get_reconfigure_entry(), data=self._config
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=self._config[CONF_NAME],
|
||||
title=DEFAULT_NAME,
|
||||
data=self._config,
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_MODE,
|
||||
CONF_NAME,
|
||||
EntityStateAttribute,
|
||||
UnitOfLength,
|
||||
UnitOfTime,
|
||||
@@ -83,7 +82,7 @@ async def async_setup_entry(
|
||||
"""Add HERE travel time entities from a config_entry."""
|
||||
|
||||
entry_id = config_entry.entry_id
|
||||
name = config_entry.data[CONF_NAME]
|
||||
name = config_entry.title
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
sensors: list[HERETravelTimeSensor] = [
|
||||
|
||||
@@ -50,8 +50,7 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"mode": "Travel mode",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
"mode": "Travel mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
from homeassistant.helpers.template import Template
|
||||
|
||||
@@ -106,7 +106,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, options[CONF_ENTITY_ID]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -331,7 +331,10 @@ class HTML5PushCallbackView(HomeAssistantView):
|
||||
if target_check.get(ATTR_TARGET) in self.registrations:
|
||||
possible_target = self.registrations[target_check[ATTR_TARGET]]
|
||||
key = possible_target["subscription"]["keys"]["auth"]
|
||||
with suppress(jwt.exceptions.DecodeError), warnings.catch_warnings():
|
||||
with (
|
||||
suppress(jwt.exceptions.DecodeError, jwt.exceptions.InvalidKeyError),
|
||||
warnings.catch_warnings(),
|
||||
):
|
||||
warnings.simplefilter("ignore", InsecureKeyLengthWarning)
|
||||
return jwt.decode(token, key, algorithms=["ES256", "HS256"])
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pydrawise"],
|
||||
"requirements": ["pydrawise==2026.4.0"]
|
||||
"requirements": ["pydrawise==2026.7.0"]
|
||||
}
|
||||
|
||||
@@ -43,4 +43,4 @@ PTZ_MOVE_DURATION_MS = 500
|
||||
# Upper bound for a full coordinator refresh (device list + status for all devices).
|
||||
UPDATE_TIMEOUT = 300
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.CAMERA, Platform.SWITCH]
|
||||
PLATFORMS = [Platform.BUTTON, Platform.CAMERA, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
@@ -56,6 +56,8 @@ class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]):
|
||||
or self._device_key not in self.coordinator.devices_by_key
|
||||
):
|
||||
return False
|
||||
if self._entity_type == PARAM_STATUS:
|
||||
return True
|
||||
if PARAM_STATUS not in self.device.sensors:
|
||||
return False
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,23 @@
|
||||
"default": "mdi:arrow-up-bold"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"status": {
|
||||
"default": "mdi:lan-connect",
|
||||
"state": {
|
||||
"offline": "mdi:close-network",
|
||||
"online": "mdi:check-network",
|
||||
"sleep": "mdi:sleep",
|
||||
"upgrading": "mdi:cloud-arrow-up"
|
||||
}
|
||||
},
|
||||
"storage_used": {
|
||||
"default": "mdi:harddisk"
|
||||
},
|
||||
"switch_cnt": {
|
||||
"default": "mdi:counter"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"ab_alarm_sound": {
|
||||
"default": "mdi:home-sound-in"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Support for Imou sensor entities."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import override
|
||||
|
||||
from pyimouapi.const import PARAM_STATE_VARIANT, STATE_VARIANT_NUMERIC
|
||||
from pyimouapi.ha_device import ImouHaDevice
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import PARAM_STATE, PARAM_STATUS, imou_device_identifier
|
||||
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
|
||||
from .entity import ImouEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
STATUS_OPTIONS = ["online", "offline", "sleep", "upgrading"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ImouSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Imou sensor entity."""
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: dict[str, ImouSensorEntityDescription] = {
|
||||
PARAM_STATUS: ImouSensorEntityDescription(
|
||||
key=PARAM_STATUS,
|
||||
translation_key=PARAM_STATUS,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
options=STATUS_OPTIONS,
|
||||
),
|
||||
"battery": ImouSensorEntityDescription(
|
||||
key="battery",
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
"storage_used": ImouSensorEntityDescription(
|
||||
key="storage_used",
|
||||
translation_key="storage_used",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
"temperature_current": ImouSensorEntityDescription(
|
||||
key="temperature_current",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
"humidity_current": ImouSensorEntityDescription(
|
||||
key="humidity_current",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
"power": ImouSensorEntityDescription(
|
||||
key="power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"voltage": ImouSensorEntityDescription(
|
||||
key="voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"current": ImouSensorEntityDescription(
|
||||
key="current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"switch_cnt": ImouSensorEntityDescription(
|
||||
key="switch_cnt",
|
||||
translation_key="switch_cnt",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
"use_electricity": ImouSensorEntityDescription(
|
||||
key="use_electricity",
|
||||
translation_key="use_electricity",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
"use_time": ImouSensorEntityDescription(
|
||||
key="use_time",
|
||||
translation_key="use_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _iter_sensors(
|
||||
coordinator: ImouDataUpdateCoordinator,
|
||||
) -> list[tuple[ImouSensorEntityDescription, ImouHaDevice]]:
|
||||
"""Return (description, device) pairs for supported sensors."""
|
||||
return [
|
||||
(SENSOR_DESCRIPTIONS[sensor_type], device)
|
||||
for device in coordinator.devices
|
||||
for sensor_type in device.sensors
|
||||
if sensor_type in SENSOR_DESCRIPTIONS
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ImouConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Imou sensor entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _add_sensors(new_devices: list[ImouHaDevice]) -> None:
|
||||
device_keys = {imou_device_identifier(device) for device in new_devices}
|
||||
async_add_entities(
|
||||
ImouSensor(coordinator, description, device)
|
||||
for description, device in _iter_sensors(coordinator)
|
||||
if imou_device_identifier(device) in device_keys
|
||||
)
|
||||
|
||||
coordinator.new_device_callbacks.append(_add_sensors)
|
||||
|
||||
@callback
|
||||
def _remove_new_device_callback() -> None:
|
||||
if _add_sensors in coordinator.new_device_callbacks:
|
||||
coordinator.new_device_callbacks.remove(_add_sensors)
|
||||
|
||||
entry.async_on_unload(_remove_new_device_callback)
|
||||
_add_sensors(coordinator.devices)
|
||||
|
||||
|
||||
class ImouSensor(ImouEntity, SensorEntity):
|
||||
"""Imou sensor entity."""
|
||||
|
||||
entity_description: ImouSensorEntityDescription
|
||||
|
||||
@property
|
||||
def _is_numeric_variant(self) -> bool:
|
||||
"""Return True when the sensor value is numeric."""
|
||||
return (
|
||||
self.device.sensors[self._entity_type].get(PARAM_STATE_VARIANT)
|
||||
== STATE_VARIANT_NUMERIC
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the sensor value.
|
||||
|
||||
Numeric sensors only expose numeric values; error codes such as
|
||||
storage_used e1/e2 become None (unknown) instead of mixing enum states.
|
||||
"""
|
||||
value = self.device.sensors[self._entity_type][PARAM_STATE]
|
||||
if self.entity_description.device_class == SensorDeviceClass.ENUM:
|
||||
return value
|
||||
if not self._is_numeric_variant:
|
||||
return None
|
||||
return value
|
||||
@@ -50,6 +50,29 @@
|
||||
"name": "Live view SD"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"offline": "[%key:common::state::disconnected%]",
|
||||
"online": "[%key:common::state::connected%]",
|
||||
"sleep": "[%key:common::state::standby%]",
|
||||
"upgrading": "Upgrading"
|
||||
}
|
||||
},
|
||||
"storage_used": {
|
||||
"name": "Storage used"
|
||||
},
|
||||
"switch_cnt": {
|
||||
"name": "Cycles today"
|
||||
},
|
||||
"use_electricity": {
|
||||
"name": "Energy consumption"
|
||||
},
|
||||
"use_time": {
|
||||
"name": "Usage duration"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"ab_alarm_sound": {
|
||||
"name": "Abnormal sound alarm"
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
|
||||
from .const import CONF_SOURCE_SENSOR
|
||||
@@ -55,7 +55,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, options[CONF_SOURCE_SENSOR]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -4,7 +4,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EXCLUDE, Platform
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -108,6 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry,
|
||||
unique_id=controller.device_uid,
|
||||
title=new_title,
|
||||
data={CONF_HOST: controller.device_ip},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -242,8 +242,7 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
await self.async_set_unique_id(uid)
|
||||
self._abort_if_unique_id_configured()
|
||||
# Discovery host is for confirm-step context only; runtime discovery owns
|
||||
# current device IP state and keeps it up to date independently of entry data.
|
||||
# Persist through confirm into entry data as CONF_HOST.
|
||||
self._discovered_controller_ip = host
|
||||
return await self.async_step_confirm()
|
||||
|
||||
@@ -357,7 +356,7 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=self._entry_title(controller.device_uid),
|
||||
data={},
|
||||
data={CONF_HOST: controller.device_ip},
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pizone"],
|
||||
"requirements": ["python-izone==1.3.4"]
|
||||
"requirements": ["python-izone==1.3.6"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"xknx==3.16.0",
|
||||
"xknxproject==3.9.0",
|
||||
"knx-frontend==2026.6.23.203726",
|
||||
"knx-telegram-store[sqlite,postgres]==0.10.1"
|
||||
"knx-telegram-store[sqlite,postgres]==0.10.2"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==13.3.0"]
|
||||
"requirements": ["ical==14.0.1"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==13.3.0"]
|
||||
"requirements": ["ical==14.0.1"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
|
||||
from .const import CONF_INDOOR_HUMIDITY, CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP
|
||||
@@ -104,7 +104,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, config_entry.options[CONF_INDOOR_HUMIDITY]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -210,6 +210,9 @@ ALARM_DESCRIPTIONS: list[OverkizAlarmDescription] = [
|
||||
SUPPORTED_DEVICES = {description.key: description for description in ALARM_DESCRIPTIONS}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OverkizDataConfigEntry,
|
||||
|
||||
@@ -163,6 +163,9 @@ SUPPORTED_STATES = {
|
||||
}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OverkizDataConfigEntry,
|
||||
|
||||
@@ -98,6 +98,9 @@ SUPPORTED_COMMANDS = {
|
||||
}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OverkizDataConfigEntry,
|
||||
|
||||
@@ -88,6 +88,9 @@ WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = {
|
||||
}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OverkizDataConfigEntry,
|
||||
|
||||
+13
-3
@@ -167,7 +167,13 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
|
||||
@override
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature."""
|
||||
if state := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE):
|
||||
# core:TargetTemperatureState stays pinned to comfort in auto mode.
|
||||
state_name = (
|
||||
OverkizState.IO_EFFECTIVE_TEMPERATURE_SETPOINT
|
||||
if self.hvac_mode == HVACMode.AUTO
|
||||
else OverkizState.CORE_TARGET_TEMPERATURE
|
||||
)
|
||||
if state := self.device.states.get(state_name):
|
||||
return state.value_as_float
|
||||
return None
|
||||
|
||||
@@ -187,6 +193,10 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new temperature."""
|
||||
temperature = kwargs[ATTR_TEMPERATURE]
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.SET_TARGET_TEMPERATURE, temperature
|
||||
# setTargetTemperature would overwrite comfort instead of the preset.
|
||||
command = (
|
||||
OverkizCommand.SET_DEROGATED_TARGET_TEMPERATURE
|
||||
if self.hvac_mode == HVACMode.AUTO
|
||||
else OverkizCommand.SET_TARGET_TEMPERATURE
|
||||
)
|
||||
await self.executor.async_execute_command(command, temperature)
|
||||
|
||||
@@ -36,7 +36,11 @@ from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
@@ -126,6 +130,25 @@ class OverkizConfigFlow(
|
||||
|
||||
return user_input
|
||||
|
||||
def _async_finish_validated_entry(
|
||||
self, user_input: dict[str, Any], title: str
|
||||
) -> ConfigFlowResult:
|
||||
"""Create or update the entry once credentials have been validated."""
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="reauth_wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=user_input
|
||||
)
|
||||
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch(reason="reconfigure_wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(), data=user_input
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -139,10 +162,6 @@ class OverkizConfigFlow(
|
||||
if self._server in SERVERS_WITH_LOCAL_API:
|
||||
return await self.async_step_local_or_cloud()
|
||||
|
||||
# Rexel authenticates via OAuth2 (Azure AD B2C with PKCE).
|
||||
if self._server == Server.REXEL:
|
||||
return await self.async_step_pick_implementation()
|
||||
|
||||
return await self.async_step_cloud()
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -262,18 +281,8 @@ class OverkizConfigFlow(
|
||||
errors["base"] = "unknown"
|
||||
LOGGER.exception("Unknown error")
|
||||
else:
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="reauth_wrong_account")
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
|
||||
# Create new entry
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME], data=user_input
|
||||
return self._async_finish_validated_entry(
|
||||
user_input, title=user_input[CONF_USERNAME]
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -332,18 +341,8 @@ class OverkizConfigFlow(
|
||||
errors["base"] = "unknown"
|
||||
LOGGER.exception("Unknown error")
|
||||
else:
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="reauth_wrong_account")
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
|
||||
# Create new entry
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST], data=user_input
|
||||
return self._async_finish_validated_entry(
|
||||
user_input, title=user_input[CONF_HOST]
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -433,6 +432,12 @@ class OverkizConfigFlow(
|
||||
self._get_reauth_entry(), data=data
|
||||
)
|
||||
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch(reason="reconfigure_wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(), data=data
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=gateway.label or "Rexel", data=data)
|
||||
@@ -471,23 +476,27 @@ class OverkizConfigFlow(
|
||||
if discovery_info.type == "_kizboxdev._tcp.local.":
|
||||
self._host = f"{discovery_info.hostname[:-1]}:{discovery_info.port}"
|
||||
self._api_type = APIType.LOCAL
|
||||
return await self._process_discovery(
|
||||
gateway_id, updates={CONF_HOST: self._host}
|
||||
)
|
||||
|
||||
return await self._process_discovery(gateway_id)
|
||||
|
||||
async def _process_discovery(self, gateway_id: str) -> ConfigFlowResult:
|
||||
async def _process_discovery(
|
||||
self, gateway_id: str, *, updates: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle discovery of a gateway."""
|
||||
await self.async_set_unique_id(gateway_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
self._abort_if_unique_id_configured(updates=updates)
|
||||
self.context["title_placeholders"] = {"gateway_id": gateway_id}
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth."""
|
||||
# Overkiz entries always have unique IDs
|
||||
self.context["title_placeholders"] = {"gateway_id": cast(str, self.unique_id)}
|
||||
def _init_flow_from_entry(
|
||||
self, entry_data: Mapping[str, Any], gateway_id: str
|
||||
) -> None:
|
||||
"""Initialize the flow's state from an existing entry for reauth/reconfigure."""
|
||||
self.context["title_placeholders"] = {"gateway_id": gateway_id}
|
||||
self._api_type = entry_data.get(CONF_API_TYPE, APIType.CLOUD)
|
||||
self._server = entry_data[CONF_HUB]
|
||||
|
||||
@@ -498,4 +507,17 @@ class OverkizConfigFlow(
|
||||
elif self._server != Server.REXEL:
|
||||
self._user = entry_data[CONF_USERNAME]
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth."""
|
||||
self._init_flow_from_entry(entry_data, cast(str, self.unique_id))
|
||||
return await self.async_step_user(dict(entry_data))
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
entry = self._get_reconfigure_entry()
|
||||
self._init_flow_from_entry(entry.data, cast(str, entry.unique_id))
|
||||
return await self.async_step_user(dict(entry.data))
|
||||
|
||||
@@ -523,6 +523,9 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
SUPPORTED_DEVICES = {description.key: description for description in COVER_DESCRIPTIONS}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OverkizDataConfigEntry,
|
||||
|
||||
@@ -48,6 +48,9 @@ FAN_DESCRIPTIONS: list[OverkizFanDescription] = [
|
||||
SUPPORTED_DEVICES = {description.key: description for description in FAN_DESCRIPTIONS}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OverkizDataConfigEntry,
|
||||
|
||||
@@ -18,6 +18,8 @@ from . import OverkizDataConfigEntry
|
||||
from .coordinator import OverkizDataUpdateCoordinator
|
||||
from .entity import OverkizEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -12,6 +12,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import OverkizDataConfigEntry
|
||||
from .entity import OverkizEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -222,6 +222,9 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [
|
||||
SUPPORTED_STATES = {description.key: description for description in NUMBER_DESCRIPTIONS}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OverkizDataConfigEntry,
|
||||
|
||||
@@ -32,7 +32,7 @@ rules:
|
||||
action-exceptions: done
|
||||
docs-installation-parameters: done
|
||||
integration-owner: done
|
||||
parallel-updates: todo
|
||||
parallel-updates: done
|
||||
test-coverage: todo
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
docs-examples: todo
|
||||
discovery-update-info: todo
|
||||
discovery-update-info: done
|
||||
entity-device-class: done
|
||||
entity-translations: todo
|
||||
docs-data-update: done
|
||||
@@ -55,7 +55,7 @@ rules:
|
||||
stale-devices: todo
|
||||
docs-supported-functions: todo
|
||||
repair-issues: todo
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
entity-category: done
|
||||
dynamic-devices: todo
|
||||
docs-troubleshooting: todo
|
||||
|
||||
@@ -11,6 +11,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import OverkizDataConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -122,6 +122,9 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [
|
||||
SUPPORTED_STATES = {description.key: description for description in SELECT_DESCRIPTIONS}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OverkizDataConfigEntry,
|
||||
|
||||
@@ -538,6 +538,9 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
|
||||
SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCRIPTIONS}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OverkizDataConfigEntry,
|
||||
|
||||
@@ -17,6 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import OverkizDataConfigEntry
|
||||
from .entity import OverkizEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reauth_wrong_account": "You can only reauthenticate this entry with the same Overkiz account and hub",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"reconfigure_wrong_account": "You can only reconfigure this entry with the same Overkiz account and hub",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -114,6 +114,9 @@ SUPPORTED_DEVICES = {
|
||||
}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OverkizDataConfigEntry,
|
||||
|
||||
@@ -21,6 +21,8 @@ from .atlantic_pass_apc_dhw import AtlanticPassAPCDHW
|
||||
from .domestic_hot_water_production import DomesticHotWaterProduction
|
||||
from .hitachi_dhw import HitachiDHW
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
LOCATION,
|
||||
SELECT_DHW_MODE,
|
||||
SELECT_GATEWAY_MODE,
|
||||
SELECT_REGULATION_MODE,
|
||||
@@ -106,9 +107,12 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity):
|
||||
self._attr_unique_id = f"{device_id}-{entity_description.key}"
|
||||
self.entity_description = entity_description
|
||||
|
||||
self._location = device_id
|
||||
if (location := self.device.get("location")) is not None:
|
||||
self._location = location
|
||||
self._device_or_location = device_id
|
||||
if (
|
||||
self.entity_description.key in (SELECT_SCHEDULE, SELECT_ZONE_PROFILE)
|
||||
and (location := self.device.get(LOCATION)) is not None
|
||||
):
|
||||
self._device_or_location = location
|
||||
|
||||
@property
|
||||
@override
|
||||
@@ -127,8 +131,10 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity):
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change to the selected entity option.
|
||||
|
||||
self._location and STATE_ON are required for the thermostat-schedule select.
|
||||
The appliance ID (= device_id) is required for the dhw_mode select.
|
||||
The location ID is required for the thermostat schedule and zone_profile selects.
|
||||
STATE_ON is required for the thermostat schedule select.
|
||||
"""
|
||||
await self.coordinator.api.set_select(
|
||||
self.entity_description.key, self._location, option, STATE_ON
|
||||
self.entity_description.key, self._device_or_location, option, STATE_ON
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from itertools import chain
|
||||
from typing import override
|
||||
|
||||
from pyportainer import StackType
|
||||
from pyportainer.models.docker import DockerSystemDF
|
||||
from pyportainer.models.docker import DockerContainerState, DockerSystemDF
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
EntityCategory,
|
||||
@@ -84,7 +84,7 @@ CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = (
|
||||
translation_key="container_state",
|
||||
value_fn=lambda data: data.container.state,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["running", "exited", "paused", "restarting", "created", "dead"],
|
||||
options=[state.value for state in DockerContainerState],
|
||||
),
|
||||
PortainerContainerSensorEntityDescription(
|
||||
key="memory_limit",
|
||||
@@ -315,17 +315,11 @@ STACK_SENSORS: tuple[PortainerStackSensorEntityDescription, ...] = (
|
||||
PortainerStackSensorEntityDescription(
|
||||
key="stack_type",
|
||||
translation_key="stack_type",
|
||||
value_fn=lambda data: (
|
||||
"swarm"
|
||||
if data.stack.type == StackType.SWARM
|
||||
else "compose"
|
||||
if data.stack.type == StackType.COMPOSE
|
||||
else "kubernetes"
|
||||
if data.stack.type == StackType.KUBERNETES
|
||||
else None
|
||||
),
|
||||
value_fn=lambda data: {
|
||||
stack.value: stack.name.lower() for stack in StackType
|
||||
}.get(data.stack.type),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["swarm", "compose", "kubernetes"],
|
||||
options=[stack.name.lower() for stack in StackType],
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
PortainerStackSensorEntityDescription(
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"dead": "Dead",
|
||||
"exited": "Exited",
|
||||
"paused": "Paused",
|
||||
"removing": "Removing",
|
||||
"restarting": "Restarting",
|
||||
"running": "Running"
|
||||
}
|
||||
|
||||
@@ -112,9 +112,7 @@ async def async_setup_entry(
|
||||
class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity):
|
||||
"""Representation of a Portainer container update."""
|
||||
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
_attr_supported_features = UpdateEntityFeature.INSTALL
|
||||
|
||||
entity_description: PortainerContainerUpdateEntityDescription
|
||||
|
||||
@@ -130,7 +128,6 @@ class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity
|
||||
super().__init__(coordinator, entity_description, device_info, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
|
||||
self._in_progress_old_version: str | None = None
|
||||
|
||||
@override
|
||||
@property
|
||||
@@ -152,18 +149,11 @@ class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity
|
||||
"""Return latest version."""
|
||||
return self.entity_description.latest_version(self.container_data.image_status)
|
||||
|
||||
@override
|
||||
@property
|
||||
def in_progress(self) -> bool:
|
||||
"""Return if an update is in progress."""
|
||||
return self._in_progress_old_version == self.installed_version
|
||||
|
||||
@override
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install update."""
|
||||
self._in_progress_old_version = self.installed_version
|
||||
try:
|
||||
await self.entity_description.update_func(
|
||||
self.coordinator.portainer,
|
||||
@@ -183,5 +173,3 @@ class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity
|
||||
) from ex
|
||||
else:
|
||||
await self.coordinator.async_request_refresh()
|
||||
finally:
|
||||
self._in_progress_old_version = None
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==13.3.0"]
|
||||
"requirements": ["ical==14.0.1"]
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ BINARY_SENSOR_DESCRIPTIONS = [
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.status.dirty_water_box_status,
|
||||
is_dock_entity=True,
|
||||
support_fn=lambda api: api.wash_towel_mode is not None,
|
||||
support_fn=lambda api: api.device_features.is_field_supported(
|
||||
StatusV2, StatusField.DIRTY_WATER_BOX_STATUS
|
||||
),
|
||||
),
|
||||
RoborockBinarySensorDescription(
|
||||
key="clean_box_empty",
|
||||
@@ -107,7 +109,9 @@ BINARY_SENSOR_DESCRIPTIONS = [
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.status.clear_water_box_status,
|
||||
is_dock_entity=True,
|
||||
support_fn=lambda api: api.wash_towel_mode is not None,
|
||||
support_fn=lambda api: api.device_features.is_field_supported(
|
||||
StatusV2, StatusField.CLEAR_WATER_BOX_STATUS
|
||||
),
|
||||
),
|
||||
RoborockBinarySensorDescription(
|
||||
key="clean_fluid_empty",
|
||||
@@ -120,9 +124,8 @@ BINARY_SENSOR_DESCRIPTIONS = [
|
||||
else None
|
||||
),
|
||||
is_dock_entity=True,
|
||||
support_fn=lambda api: (
|
||||
api.wash_towel_mode is not None
|
||||
and api.device_features.is_clean_fluid_delivery_supported
|
||||
support_fn=lambda api: api.device_features.is_field_supported(
|
||||
StatusV2, StatusField.CLEAN_FLUID_STATUS
|
||||
),
|
||||
),
|
||||
RoborockBinarySensorDescription(
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==5.30.0",
|
||||
"python-roborock==5.31.1",
|
||||
"vacuum-map-parser-roborock==0.1.5"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -268,6 +268,8 @@ async def async_setup_entry(
|
||||
if (options := description.options_lambda(coordinator.properties_api))
|
||||
is not None
|
||||
)
|
||||
if coordinator.properties_api.status.cleaning_mode_options:
|
||||
entities.append(RoborockCleaningModeSelectEntity(coordinator))
|
||||
if (
|
||||
coordinator.properties_api.home is not None
|
||||
and coordinator.properties_api.maps is not None
|
||||
@@ -386,6 +388,47 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
|
||||
return self.entity_description.value_fn(self.coordinator.properties_api)
|
||||
|
||||
|
||||
class RoborockCleaningModeSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
|
||||
"""A class to let you set the high-level cleaning mode on a Roborock vacuum.
|
||||
|
||||
This bundles the fan speed, water flow, and mop route settings into a
|
||||
single choice, e.g. vacuum only, mop only, or vacuum and mop.
|
||||
"""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "cleaning_mode"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RoborockDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Create a select entity for the high-level cleaning mode."""
|
||||
super().__init__(f"cleaning_mode_{coordinator.duid_slug}", coordinator)
|
||||
self._status_trait = coordinator.properties_api.status
|
||||
self._attr_options = [
|
||||
mode.value for mode in self._status_trait.cleaning_mode_options
|
||||
]
|
||||
|
||||
@property
|
||||
@override
|
||||
def current_option(self) -> str | None:
|
||||
"""Get the current high-level cleaning mode."""
|
||||
return self._status_trait.current_cleaning_mode_name
|
||||
|
||||
@override
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the high-level cleaning mode."""
|
||||
try:
|
||||
await self._status_trait.set_cleaning_mode(option)
|
||||
except RoborockException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={"command": "cleaning_mode"},
|
||||
) from err
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
|
||||
class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
|
||||
"""A class to let you set the selected map on Roborock vacuum."""
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from roborock.data import (
|
||||
ZeoState,
|
||||
)
|
||||
from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceState
|
||||
from roborock.data.v1.v1_containers import StatusField, StatusV2
|
||||
from roborock.devices.traits.b01.q10.status import StatusTrait as Q10StatusTrait
|
||||
from roborock.devices.traits.v1 import PropertiesApi
|
||||
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
|
||||
@@ -259,9 +260,9 @@ SENSOR_DESCRIPTIONS = [
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=RoborockDockErrorCode.keys(),
|
||||
is_dock_entity=True,
|
||||
# Only available with more than just the basic dock. Dust collection
|
||||
# mode is a proxy for any more complex dock type (e.g. Auto-empty).
|
||||
support_fn=lambda api: api.dust_collection_mode is not None,
|
||||
support_fn=lambda api: api.device_features.is_field_supported(
|
||||
StatusV2, StatusField.DOCK_ERROR_STATUS
|
||||
),
|
||||
),
|
||||
RoborockSensorDescription(
|
||||
key="mop_clean_remaining",
|
||||
|
||||
@@ -139,7 +139,9 @@
|
||||
"cleaning_mode": {
|
||||
"name": "Cleaning mode",
|
||||
"state": {
|
||||
"custom": "Custom",
|
||||
"mop": "Mop only",
|
||||
"smart_mode": "Smart",
|
||||
"vac_and_mop": "Vacuum and mop",
|
||||
"vacuum": "Vacuum only"
|
||||
}
|
||||
|
||||
@@ -182,13 +182,16 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
|
||||
what was available when the area mapping was last configured.
|
||||
"""
|
||||
super()._handle_coordinator_update()
|
||||
# Avoid creating false-alarm issues if home map info is not yet loaded
|
||||
if self._home_trait.home_map_info is None:
|
||||
return
|
||||
last_seen = self.last_seen_segments
|
||||
if last_seen is None:
|
||||
# No area mapping has been configured yet; nothing to check.
|
||||
return
|
||||
current_ids = {
|
||||
f"{map_flag}_{room.segment_id}"
|
||||
for map_flag, map_info in (self._home_trait.home_map_info or {}).items()
|
||||
for map_flag, map_info in self._home_trait.home_map_info.items()
|
||||
for room in map_info.rooms
|
||||
}
|
||||
if current_ids != {seg.id for seg in last_seen}:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/schlage",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["pyschlage==2025.9.0"]
|
||||
"requirements": ["pyschlage==2026.7.0"]
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
"60": "1 minute",
|
||||
"120": "2 minutes",
|
||||
"240": "4 minutes",
|
||||
"300": "5 minutes"
|
||||
"300": "5 minutes",
|
||||
"360": "6 minutes",
|
||||
"600": "10 minutes"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.const import (
|
||||
CONF_MODEL,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -294,6 +295,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
|
||||
entry.data.get(CONF_PASSWORD),
|
||||
device_mac=entry.unique_id,
|
||||
port=get_http_port(entry.data),
|
||||
verify_ssl=entry.data.get(CONF_VERIFY_SSL, False),
|
||||
)
|
||||
|
||||
ws_context = await get_ws_context(hass)
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import LOGGER
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -62,7 +61,7 @@ def async_register_zeroconf_discovery(
|
||||
|
||||
state = registry.get(normalized_mac)
|
||||
if not state:
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"No BLE provisioning state found for %s (host %s, port %s)",
|
||||
normalized_mac,
|
||||
host,
|
||||
@@ -70,7 +69,7 @@ def async_register_zeroconf_discovery(
|
||||
)
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
LOGGER.debug(
|
||||
"Registering zeroconf discovery for %s at %s:%s (replacing previous)",
|
||||
normalized_mac,
|
||||
host,
|
||||
|
||||
@@ -13,7 +13,12 @@ from aioshelly.ble.manufacturer_data import (
|
||||
)
|
||||
from aioshelly.block_device import BlockDevice
|
||||
from aioshelly.common import ConnectionOptions, get_info
|
||||
from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS
|
||||
from aioshelly.const import (
|
||||
BLOCK_GENERATIONS,
|
||||
DEFAULT_HTTP_PORT,
|
||||
DEFAULT_HTTPS_PORT,
|
||||
RPC_GENERATIONS,
|
||||
)
|
||||
from aioshelly.exceptions import (
|
||||
CustomPortNotSupported,
|
||||
DeviceConnectionError,
|
||||
@@ -51,6 +56,7 @@ from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
@@ -97,6 +103,7 @@ CONFIG_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_HTTP_PORT): vol.Coerce(int),
|
||||
vol.Optional(CONF_VERIFY_SSL, default=False): bool,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -144,6 +151,7 @@ async def validate_input(
|
||||
port: int,
|
||||
info: dict[str, Any],
|
||||
data: dict[str, Any],
|
||||
verify_ssl: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
@@ -155,6 +163,7 @@ async def validate_input(
|
||||
password=data.get(CONF_PASSWORD),
|
||||
device_mac=info[CONF_MAC],
|
||||
port=port,
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
|
||||
gen = get_info_gen(info)
|
||||
@@ -210,6 +219,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
host: str = ""
|
||||
port: int = DEFAULT_HTTP_PORT
|
||||
verify_ssl: bool = False
|
||||
info: dict[str, Any] = {}
|
||||
device_info: dict[str, Any] = {}
|
||||
ble_device: BLEDevice | None = None
|
||||
@@ -223,6 +233,21 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_discovered_devices: dict[str, DiscoveredDeviceZeroconf | DiscoveredDeviceBluetooth]
|
||||
_ble_rpc_device: RpcDevice | None = None
|
||||
|
||||
@staticmethod
|
||||
def _get_ssl_entry_data(port: int, verify_ssl: bool) -> dict[str, bool]:
|
||||
"""Return SSL verification config entry data for HTTPS devices only."""
|
||||
if port != DEFAULT_HTTPS_PORT:
|
||||
return {}
|
||||
return {CONF_VERIFY_SSL: verify_ssl}
|
||||
|
||||
@staticmethod
|
||||
def _check_enhanced_security(info: dict[str, Any], port: int) -> int:
|
||||
"""Return HTTPS port if device reports enhanced_security is enabled."""
|
||||
if info.get("enhanced_security"):
|
||||
return DEFAULT_HTTPS_PORT
|
||||
|
||||
return port
|
||||
|
||||
@staticmethod
|
||||
def _get_name_from_mac_and_ble_model(
|
||||
mac: str, parsed_data: dict[str, int | str]
|
||||
@@ -407,7 +432,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return discovered
|
||||
|
||||
async def _async_connect_and_get_info(
|
||||
self, host: str, port: int
|
||||
self, host: str, port: int, verify_ssl: bool = False
|
||||
) -> ConfigFlowResult | None:
|
||||
"""Connect to device, validate, and create entry or return None.
|
||||
|
||||
@@ -419,18 +444,19 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
Sets self.info, self.host, and self.port on success.
|
||||
"""
|
||||
self.info = await self._async_get_info(host, port)
|
||||
self.info = await self._async_get_info(host, port, verify_ssl)
|
||||
await self.async_set_unique_id(self.info[CONF_MAC], raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: host})
|
||||
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.port = self._check_enhanced_security(self.info, port)
|
||||
self.verify_ssl = verify_ssl
|
||||
|
||||
if get_info_auth(self.info):
|
||||
return None # Continue to credentials step
|
||||
|
||||
device_info = await validate_input(
|
||||
self.hass, self.host, self.port, self.info, {}
|
||||
self.hass, self.host, self.port, self.info, {}, self.verify_ssl
|
||||
)
|
||||
|
||||
if device_info[CONF_MODEL]:
|
||||
@@ -442,6 +468,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
|
||||
CONF_MODEL: device_info[CONF_MODEL],
|
||||
CONF_GEN: device_info[CONF_GEN],
|
||||
**self._get_ssl_entry_data(self.port, self.verify_ssl),
|
||||
},
|
||||
)
|
||||
return self.async_abort(reason="firmware_not_fully_provisioned")
|
||||
@@ -463,7 +490,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
# Zeroconf device - connect directly
|
||||
try:
|
||||
result = await self._async_connect_and_get_info(
|
||||
device_data.host, device_data.port
|
||||
device_data.host, device_data.port, verify_ssl=False
|
||||
)
|
||||
except AbortFlow:
|
||||
raise # Let AbortFlow propagate (e.g., already_configured)
|
||||
@@ -551,7 +578,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
try:
|
||||
result = await self._async_connect_and_get_info(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
user_input[CONF_HOST],
|
||||
user_input[CONF_PORT],
|
||||
user_input[CONF_VERIFY_SSL],
|
||||
)
|
||||
except AbortFlow:
|
||||
raise # Let AbortFlow propagate (e.g., already_configured)
|
||||
@@ -586,7 +615,12 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_USERNAME] = "admin"
|
||||
try:
|
||||
device_info = await validate_input(
|
||||
self.hass, self.host, self.port, self.info, user_input
|
||||
self.hass,
|
||||
self.host,
|
||||
self.port,
|
||||
self.info,
|
||||
user_input,
|
||||
self.verify_ssl,
|
||||
)
|
||||
except InvalidAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
@@ -608,6 +642,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
|
||||
CONF_MODEL: device_info[CONF_MODEL],
|
||||
CONF_GEN: device_info[CONF_GEN],
|
||||
**self._get_ssl_entry_data(self.port, self.verify_ssl),
|
||||
},
|
||||
)
|
||||
return self.async_abort(reason="firmware_not_fully_provisioned")
|
||||
@@ -877,6 +912,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
None,
|
||||
device_mac=self.unique_id,
|
||||
port=port,
|
||||
verify_ssl=self.verify_ssl,
|
||||
)
|
||||
device: RpcDevice | None = None
|
||||
try:
|
||||
@@ -1000,19 +1036,28 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.port = state.port
|
||||
|
||||
try:
|
||||
self.info = await self._async_get_info(self.host, self.port)
|
||||
self.info = await self._async_get_info(
|
||||
self.host, self.port, self.verify_ssl
|
||||
)
|
||||
except DeviceConnectionError as err:
|
||||
LOGGER.debug("Failed to connect to device after WiFi provisioning: %s", err)
|
||||
# Device appeared on network but can't connect - allow retry
|
||||
return None
|
||||
|
||||
self.port = self._check_enhanced_security(self.info, self.port)
|
||||
|
||||
if get_info_auth(self.info):
|
||||
# Device requires authentication - show credentials step
|
||||
return await self.async_step_credentials()
|
||||
|
||||
try:
|
||||
device_info = await validate_input(
|
||||
self.hass, self.host, self.port, self.info, {}
|
||||
self.hass,
|
||||
self.host,
|
||||
self.port,
|
||||
self.info,
|
||||
{},
|
||||
self.verify_ssl,
|
||||
)
|
||||
except DeviceConnectionError as err:
|
||||
LOGGER.debug("Failed to validate device after WiFi provisioning: %s", err)
|
||||
@@ -1041,6 +1086,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
|
||||
CONF_MODEL: device_info[CONF_MODEL],
|
||||
CONF_GEN: device_info[CONF_GEN],
|
||||
**self._get_ssl_entry_data(self.port, self.verify_ssl),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1123,6 +1169,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="ipv6_not_supported")
|
||||
host = discovery_info.host
|
||||
port = discovery_info.port or DEFAULT_HTTP_PORT
|
||||
verify_ssl = False
|
||||
# First try to get the mac address from the name
|
||||
# so we can avoid making another connection to the
|
||||
# device if we already have it configured
|
||||
@@ -1132,7 +1179,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
# Devices behind range extender doesn't generate zeroconf packets
|
||||
# so port is always the default one
|
||||
self.info = await self._async_get_info(host, port)
|
||||
self.info = await self._async_get_info(host, port, verify_ssl)
|
||||
except DeviceConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
@@ -1143,10 +1190,15 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self._async_handle_zeroconf_mac_discovery(mac, host, port)
|
||||
|
||||
self.host = host
|
||||
self.port = self._check_enhanced_security(self.info, port)
|
||||
self.verify_ssl = verify_ssl
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {"name": discovery_info.name.split(".")[0]},
|
||||
"configuration_url": f"http://{discovery_info.host}",
|
||||
"configuration_url": (
|
||||
f"{'https' if self.port == DEFAULT_HTTPS_PORT else 'http'}://"
|
||||
f"{discovery_info.host}"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1155,7 +1207,12 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
self.device_info = await validate_input(
|
||||
self.hass, self.host, self.port, self.info, {}
|
||||
self.hass,
|
||||
self.host,
|
||||
self.port,
|
||||
self.info,
|
||||
{},
|
||||
self.verify_ssl,
|
||||
)
|
||||
except DeviceConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
@@ -1176,9 +1233,11 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title=self.device_info["title"],
|
||||
data={
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD],
|
||||
CONF_MODEL: self.device_info[CONF_MODEL],
|
||||
CONF_GEN: self.device_info[CONF_GEN],
|
||||
**self._get_ssl_entry_data(self.port, self.verify_ssl),
|
||||
},
|
||||
)
|
||||
self._set_confirm_only()
|
||||
@@ -1206,24 +1265,33 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
host = reauth_entry.data[CONF_HOST]
|
||||
port = get_http_port(reauth_entry.data)
|
||||
verify_ssl = reauth_entry.data.get(CONF_VERIFY_SSL, False)
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await self._async_get_info(host, port)
|
||||
info = await self._async_get_info(host, port, verify_ssl)
|
||||
except DeviceConnectionError, InvalidAuthError:
|
||||
return self.async_abort(reason="reauth_unsuccessful")
|
||||
|
||||
if get_device_entry_gen(reauth_entry) != 1:
|
||||
user_input[CONF_USERNAME] = "admin"
|
||||
|
||||
port = self._check_enhanced_security(info, port)
|
||||
|
||||
try:
|
||||
await validate_input(self.hass, host, port, info, user_input)
|
||||
await validate_input(
|
||||
self.hass, host, port, info, user_input, verify_ssl
|
||||
)
|
||||
except DeviceConnectionError, InvalidAuthError:
|
||||
return self.async_abort(reason="reauth_unsuccessful")
|
||||
except MacAddressMismatchError:
|
||||
return self.async_abort(reason="mac_address_mismatch")
|
||||
|
||||
data_updates: dict[str, Any] = {CONF_PORT: port, **user_input}
|
||||
data_updates.update(self._get_ssl_entry_data(port, verify_ssl))
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry, data_updates=user_input
|
||||
reauth_entry, data_updates=data_updates
|
||||
)
|
||||
|
||||
if get_device_entry_gen(reauth_entry) in BLOCK_GENERATIONS:
|
||||
@@ -1248,12 +1316,14 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
self.host = reconfigure_entry.data[CONF_HOST]
|
||||
self.port = reconfigure_entry.data.get(CONF_PORT, DEFAULT_HTTP_PORT)
|
||||
self.verify_ssl = reconfigure_entry.data.get(CONF_VERIFY_SSL, False)
|
||||
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input.get(CONF_PORT, DEFAULT_HTTP_PORT)
|
||||
verify_ssl = user_input.get(CONF_VERIFY_SSL, False)
|
||||
try:
|
||||
info = await self._async_get_info(host, port)
|
||||
info = await self._async_get_info(host, port, verify_ssl)
|
||||
except DeviceConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except CustomPortNotSupported:
|
||||
@@ -1262,9 +1332,21 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(info[CONF_MAC])
|
||||
self._abort_if_unique_id_mismatch(reason="another_device")
|
||||
|
||||
port = self._check_enhanced_security(info, port)
|
||||
|
||||
data_updates: dict[str, Any] = {
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
}
|
||||
if (
|
||||
port == DEFAULT_HTTPS_PORT
|
||||
or CONF_VERIFY_SSL in reconfigure_entry.data
|
||||
):
|
||||
data_updates[CONF_VERIFY_SSL] = verify_ssl
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={CONF_HOST: host, CONF_PORT: port},
|
||||
data_updates=data_updates,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -1273,15 +1355,20 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
{
|
||||
vol.Required(CONF_HOST, default=self.host): str,
|
||||
vol.Required(CONF_PORT, default=self.port): vol.Coerce(int),
|
||||
vol.Optional(CONF_VERIFY_SSL, default=self.verify_ssl): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders={"device_name": reconfigure_entry.title},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_get_info(self, host: str, port: int) -> dict[str, Any]:
|
||||
async def _async_get_info(
|
||||
self, host: str, port: int, verify_ssl: bool
|
||||
) -> dict[str, Any]:
|
||||
"""Get info from shelly device."""
|
||||
return await get_info(async_get_clientsession(self.hass), host, port=port)
|
||||
return await get_info(
|
||||
async_get_clientsession(self.hass), host, port=port, verify_ssl=verify_ssl
|
||||
)
|
||||
|
||||
@callback
|
||||
@override
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, cast, override
|
||||
|
||||
from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner
|
||||
from aioshelly.block_device import BlockDevice, BlockUpdateType
|
||||
from aioshelly.const import MODEL_VALVE
|
||||
from aioshelly.const import DEFAULT_HTTPS_PORT, MODEL_VALVE
|
||||
from aioshelly.exceptions import (
|
||||
DeviceConnectionError,
|
||||
InvalidAuthError,
|
||||
@@ -150,7 +150,9 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice](
|
||||
@cached_property
|
||||
def configuration_url(self) -> str:
|
||||
"""Return the configuration URL for the device."""
|
||||
return f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}"
|
||||
port = get_http_port(self.config_entry.data)
|
||||
scheme = "https" if port == DEFAULT_HTTPS_PORT else "http"
|
||||
return f"{scheme}://{get_host(self.config_entry.data[CONF_HOST])}:{port}"
|
||||
|
||||
@cached_property
|
||||
def model(self) -> str:
|
||||
|
||||
@@ -72,11 +72,13 @@
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::shelly::config::step::user_manual::data_description::host%]",
|
||||
"port": "[%key:component::shelly::config::step::user_manual::data_description::port%]"
|
||||
"port": "[%key:component::shelly::config::step::user_manual::data_description::port%]",
|
||||
"verify_ssl": "[%key:component::shelly::config::step::user_manual::data_description::verify_ssl%]"
|
||||
},
|
||||
"description": "Update configuration for {device_name}.\n\nBefore setup, battery-powered devices must be woken up, you can now wake the device up using a button on it."
|
||||
},
|
||||
@@ -92,11 +94,13 @@
|
||||
"user_manual": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the Shelly device to connect to.",
|
||||
"port": "The TCP port of the Shelly device to connect to (Gen2+)."
|
||||
"port": "The TCP port of the Shelly device to connect to (Gen2+).",
|
||||
"verify_ssl": "Verify SSL/TLS certificate when connecting on HTTPS (port 443, Gen2+)."
|
||||
},
|
||||
"description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it."
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Final, cast, override
|
||||
|
||||
from aioshelly.const import RPC_GENERATIONS
|
||||
@@ -25,6 +24,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from .const import (
|
||||
CONF_SLEEP_PERIOD,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
OTA_BEGIN,
|
||||
OTA_ERROR,
|
||||
OTA_PROGRESS,
|
||||
@@ -42,8 +42,6 @@ from .entity import (
|
||||
)
|
||||
from .utils import get_device_entry_gen, get_release_url
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
|
||||
@@ -155,7 +155,11 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
try:
|
||||
return await command(*args, **kwargs)
|
||||
except SmlightAuthError as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from err
|
||||
except SmlightConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -181,6 +181,9 @@
|
||||
"firmware_update_failed": {
|
||||
"message": "Firmware update failed for {device_name}."
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"no_device_found": {
|
||||
"message": "No valid SMLIGHT device found for the given targets."
|
||||
},
|
||||
|
||||
@@ -264,8 +264,11 @@ class SonosDiscoveryManager:
|
||||
visible_zones = soco.visible_zones
|
||||
self._known_invisible = soco.all_zones - visible_zones
|
||||
for zone in visible_zones:
|
||||
if zone.uid not in self.data.discovered:
|
||||
zones_to_add.add(zone)
|
||||
if zone.uid in self.data.discovered or self.is_device_disabled(
|
||||
zone.uid
|
||||
):
|
||||
continue
|
||||
zones_to_add.add(zone)
|
||||
|
||||
if not zones_to_add:
|
||||
return
|
||||
@@ -540,6 +543,16 @@ class SonosDiscoveryManager:
|
||||
self.hass, DISCOVERY_INTERVAL.total_seconds(), self.async_poll_manual_hosts
|
||||
)
|
||||
|
||||
def is_device_disabled(self, uid: str) -> bool:
|
||||
"""Check if the Sonos device is disabled in the device registry."""
|
||||
if not (
|
||||
device := dr.async_get(self.hass).async_get_device(
|
||||
identifiers={(DOMAIN, uid)}
|
||||
)
|
||||
):
|
||||
return False
|
||||
return device.disabled
|
||||
|
||||
async def _async_handle_discovery_message(
|
||||
self,
|
||||
uid: str,
|
||||
@@ -548,6 +561,10 @@ class SonosDiscoveryManager:
|
||||
boot_seqnum: int | None = None,
|
||||
) -> None:
|
||||
"""Handle discovered player creation and activity."""
|
||||
if self.is_device_disabled(uid):
|
||||
_LOGGER.debug("Skipping %s for disabled Sonos device: %s", source, uid)
|
||||
return
|
||||
|
||||
async with self.discovery_lock:
|
||||
if not self.data.discovered:
|
||||
# Initial discovery, attempt to add all visible zones
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
|
||||
DOMAIN = "statistics"
|
||||
@@ -63,7 +63,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, options[CONF_ENTITY_ID]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
|
||||
from .const import CONF_INVERT, CONF_TARGET_DOMAIN
|
||||
@@ -89,7 +89,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_get_parent_device_id(
|
||||
hass, options[CONF_ENTITY_ID]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -20,9 +20,7 @@ from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.device import (
|
||||
async_remove_stale_devices_links_keep_current_device,
|
||||
)
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
)
|
||||
from homeassistant.helpers.helper_integration import async_remove_helper_devices
|
||||
from homeassistant.helpers.reload import async_reload_integration_platforms
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -139,7 +137,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if config_entry.minor_version < 2:
|
||||
# Remove the template config entry from the source device
|
||||
if source_device_id := config_entry.options.get(CONF_DEVICE_ID):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -612,7 +612,6 @@ CONFIG_FLOW = {
|
||||
),
|
||||
Platform.IMAGE: SchemaFlowFormStep(
|
||||
config_schema(Platform.IMAGE),
|
||||
preview="template",
|
||||
validate_user_input=validate_user_input(Platform.IMAGE),
|
||||
),
|
||||
Platform.LIGHT: SchemaFlowFormStep(
|
||||
@@ -702,7 +701,6 @@ OPTIONS_FLOW = {
|
||||
),
|
||||
Platform.IMAGE: SchemaFlowFormStep(
|
||||
options_schema(Platform.IMAGE),
|
||||
preview="template",
|
||||
validate_user_input=validate_user_input(Platform.IMAGE),
|
||||
),
|
||||
Platform.LIGHT: SchemaFlowFormStep(
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -56,7 +56,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, options[CONF_ENTITY_ID]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR]
|
||||
@@ -62,7 +62,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, options[CONF_ENTITY_ID]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -259,7 +259,7 @@ def _device_wan_latency_monitor(
|
||||
) -> TypedDeviceUptimeStatsWanMonitor | None:
|
||||
"""Return the target of the WAN latency monitor."""
|
||||
if device.uptime_stats and (uptime_stats_wan := device.uptime_stats.get(wan)):
|
||||
for monitor in uptime_stats_wan["monitors"]:
|
||||
for monitor in uptime_stats_wan.get("monitors", []):
|
||||
if monitor_target in monitor["target"]:
|
||||
return monitor
|
||||
return None
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers import (
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
async_remove_helper_devices,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -264,7 +264,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if source_device_id := async_entity_id_to_device_id(
|
||||
hass, options[CONF_SOURCE_SENSOR]
|
||||
):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
async_remove_helper_devices(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"velbus-handler"
|
||||
],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["velbus-aio==2026.4.1"],
|
||||
"requirements": ["velbus-aio==2026.7.2"],
|
||||
"usb": [
|
||||
{
|
||||
"pid": "0B1B",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user