Merge branch 'dev' into frenck/add-codspeed-benchmarks

This commit is contained in:
Robert Resch
2026-07-20 14:22:19 +02:00
committed by GitHub
182 changed files with 7581 additions and 656 deletions
+1 -1
View File
@@ -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",
+11 -2
View File
@@ -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
)
+32 -6
View File
@@ -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:
+9 -1
View File
@@ -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()
+29 -22
View File
@@ -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):
+12 -11
View File
@@ -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,
+29 -2
View File
@@ -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,
)
+1 -1
View File
@@ -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][]].*",
+65
View File
@@ -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"
},
+20 -2
View File
@@ -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,
+4 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -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]
+2
View File
@@ -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 (
+17
View File
@@ -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"
+186
View File
@@ -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,
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -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,
@@ -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)
+59 -37
View File
@@ -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,
+3
View File
@@ -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,
+2
View File
@@ -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,
+11 -5
View File
@@ -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 -12
View File
@@ -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"
}
+1 -13
View File
@@ -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."""
+4 -3
View File
@@ -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"
}
+4 -1
View File
@@ -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,
+107 -20
View File
@@ -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:
+8 -4
View File
@@ -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."
},
+1 -3
View File
@@ -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."
},
+19 -2
View File
@@ -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,
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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