Co-authored-by: Shay Levy <levyshay1@gmail.com>
Co-authored-by: Allen Porter <allen.porter@gmail.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: TimL <tl@smlight.tech>
Co-authored-by: Seweryn Zeman <seweryn.zeman@jazzy.pro>
Co-authored-by: hahn-th <15319212+hahn-th@users.noreply.github.com>
Co-authored-by: Luke Lashley <conway220@gmail.com>
Co-authored-by: starkillerOG <starkiller.og@gmail.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Arie Catsman <120491684+catsmanac@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
Co-authored-by: Ruben van Dijk <15885455+RubenNL@users.noreply.github.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
Co-authored-by: Thomas55555 <59625598+Thomas55555@users.noreply.github.com>
Co-authored-by: Øyvind Matheson Wergeland <oyvind@wergeland.org>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
Co-authored-by: Brett Adams <Bre77@users.noreply.github.com>
Co-authored-by: rjblake <richard.blake@gmail.com>
Co-authored-by: Daniel Hjelseth Høyer <github@dahoiv.net>
Co-authored-by: Matthias Alphart <farmio@alphart.net>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Odd Stråbø <oddstr13@openshell.no>
Co-authored-by: puddly <32534428+puddly@users.noreply.github.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
fix privacy mode availability for NVR IPC cams (#144569)
fix enphase_envoy diagnostics home endpoint name (#144634)
Close Octoprint aiohttp session on unload (#144670)
Fix strings typo for Comelit (#144672)
Fix wrong state in Husqvarna Automower (#144684)
Fix Netgear handeling of missing MAC in device registry (#144722)
Fix blocking call in azure storage (#144803)
Fix Z-Wave unique id after controller reset (#144813)
Fix blocking call in azure_storage config flow (#144818)
Fix wall connector states in Teslemetry (#144855)
Fix Reolink setup when ONVIF push is unsupported (#144869)
Fix some Home Connect translation strings (#144905)
Fix unknown Pure AQI in Sensibo (#144924)
Fix Home Assistant Yellow config entry data (#144948)
Fix ESPHome entities unavailable if deep sleep enabled after entry setup (#144970)
fix from ZHA event `unique_id` (#145006)
Fix climate idle state for Comelit (#145059)
Fix fan AC mode in SmartThings AC (#145064)
Fix Ecovacs mower area sensors (#145071)
This commit is contained in:
Franck Nijhof 2025-05-16 23:08:52 +02:00 committed by GitHub
commit f66feabaaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 1491 additions and 301 deletions

View File

@ -39,13 +39,22 @@ async def async_setup_entry(
session = async_create_clientsession( session = async_create_clientsession(
hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60) hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60)
) )
container_client = ContainerClient(
def create_container_client() -> ContainerClient:
"""Create a ContainerClient."""
return ContainerClient(
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
container_name=entry.data[CONF_CONTAINER_NAME], container_name=entry.data[CONF_CONTAINER_NAME],
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY], credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
transport=AioHttpTransport(session=session), transport=AioHttpTransport(session=session),
) )
# has a blocking call to open in cpython
container_client: ContainerClient = await hass.async_add_executor_job(
create_container_client
)
try: try:
if not await container_client.exists(): if not await container_client.exists():
await container_client.create_container() await container_client.create_container()

View File

@ -27,9 +27,25 @@ _LOGGER = logging.getLogger(__name__)
class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for azure storage.""" """Handle a config flow for azure storage."""
def get_account_url(self, account_name: str) -> str: async def get_container_client(
"""Get the account URL.""" self, account_name: str, container_name: str, storage_account_key: str
return f"https://{account_name}.blob.core.windows.net/" ) -> ContainerClient:
"""Get the container client.
ContainerClient has a blocking call to open in cpython
"""
session = async_get_clientsession(self.hass)
def create_container_client() -> ContainerClient:
return ContainerClient(
account_url=f"https://{account_name}.blob.core.windows.net/",
container_name=container_name,
credential=storage_account_key,
transport=AioHttpTransport(session=session),
)
return await self.hass.async_add_executor_job(create_container_client)
async def validate_config( async def validate_config(
self, container_client: ContainerClient self, container_client: ContainerClient
@ -58,11 +74,10 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match( self._async_abort_entries_match(
{CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]} {CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]}
) )
container_client = ContainerClient( container_client = await self.get_container_client(
account_url=self.get_account_url(user_input[CONF_ACCOUNT_NAME]), account_name=user_input[CONF_ACCOUNT_NAME],
container_name=user_input[CONF_CONTAINER_NAME], container_name=user_input[CONF_CONTAINER_NAME],
credential=user_input[CONF_STORAGE_ACCOUNT_KEY], storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
) )
errors = await self.validate_config(container_client) errors = await self.validate_config(container_client)
@ -99,12 +114,12 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
reauth_entry = self._get_reauth_entry() reauth_entry = self._get_reauth_entry()
if user_input is not None: if user_input is not None:
container_client = ContainerClient( container_client = await self.get_container_client(
account_url=self.get_account_url(reauth_entry.data[CONF_ACCOUNT_NAME]), account_name=reauth_entry.data[CONF_ACCOUNT_NAME],
container_name=reauth_entry.data[CONF_CONTAINER_NAME], container_name=reauth_entry.data[CONF_CONTAINER_NAME],
credential=user_input[CONF_STORAGE_ACCOUNT_KEY], storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
) )
errors = await self.validate_config(container_client) errors = await self.validate_config(container_client)
if not errors: if not errors:
return self.async_update_reload_and_abort( return self.async_update_reload_and_abort(
@ -129,13 +144,10 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
reconfigure_entry = self._get_reconfigure_entry() reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None: if user_input is not None:
container_client = ContainerClient( container_client = await self.get_container_client(
account_url=self.get_account_url( account_name=reconfigure_entry.data[CONF_ACCOUNT_NAME],
reconfigure_entry.data[CONF_ACCOUNT_NAME]
),
container_name=user_input[CONF_CONTAINER_NAME], container_name=user_input[CONF_CONTAINER_NAME],
credential=user_input[CONF_STORAGE_ACCOUNT_KEY], storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
) )
errors = await self.validate_config(container_client) errors = await self.validate_config(container_client)
if not errors: if not errors:

View File

@ -77,6 +77,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) ->
coordinator = entry.runtime_data coordinator = entry.runtime_data
if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms):
await coordinator.api.logout() await coordinator.api.logout()
await coordinator.api.close()
return unload_ok return unload_ok

View File

@ -134,11 +134,9 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
self._attr_current_temperature = values[0] / 10 self._attr_current_temperature = values[0] / 10
self._attr_hvac_action = None self._attr_hvac_action = None
if _mode == ClimaComelitMode.OFF:
self._attr_hvac_action = HVACAction.OFF
if not _active: if not _active:
self._attr_hvac_action = HVACAction.IDLE self._attr_hvac_action = HVACAction.IDLE
if _mode in API_STATUS: elif _mode in API_STATUS:
self._attr_hvac_action = API_STATUS[_mode]["hvac_action"] self._attr_hvac_action = API_STATUS[_mode]["hvac_action"]
self._attr_hvac_mode = None self._attr_hvac_mode = None

View File

@ -73,7 +73,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
) from err ) from err
finally: finally:
await api.logout() await api.logout()
await api.close()
return {"title": data[CONF_HOST]} return {"title": data[CONF_HOST]}

View File

@ -8,5 +8,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aiocomelit"], "loggers": ["aiocomelit"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["aiocomelit==0.12.0"] "requirements": ["aiocomelit==0.12.1"]
} }

View File

@ -76,7 +76,7 @@
"cannot_authenticate": { "cannot_authenticate": {
"message": "Error authenticating" "message": "Error authenticating"
}, },
"updated_failed": { "update_failed": {
"message": "Failed to update data: {error}" "message": "Failed to update data: {error}"
} }
} }

View File

@ -15,7 +15,7 @@
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"aiodhcpwatcher==1.1.1", "aiodhcpwatcher==1.1.1",
"aiodiscover==2.6.1", "aiodiscover==2.7.0",
"cached-ipaddress==0.10.0" "cached-ipaddress==0.10.0"
] ]
} }

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==13.1.0"] "requirements": ["py-sucks==0.9.10", "deebot-client==13.2.0"]
} }

View File

@ -6,7 +6,8 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Generic from typing import Any, Generic
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType
from deebot_client.device import Device
from deebot_client.events import ( from deebot_client.events import (
BatteryEvent, BatteryEvent,
ErrorEvent, ErrorEvent,
@ -34,7 +35,7 @@ from homeassistant.const import (
UnitOfArea, UnitOfArea,
UnitOfTime, UnitOfTime,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
@ -59,6 +60,15 @@ class EcovacsSensorEntityDescription(
"""Ecovacs sensor entity description.""" """Ecovacs sensor entity description."""
value_fn: Callable[[EventT], StateType] value_fn: Callable[[EventT], StateType]
native_unit_of_measurement_fn: Callable[[DeviceType], str | None] | None = None
@callback
def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None:
"""Get the area native unit of measurement based on device type."""
if device_type is DeviceType.MOWER:
return UnitOfArea.SQUARE_CENTIMETERS
return UnitOfArea.SQUARE_METERS
ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
@ -68,7 +78,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
capability_fn=lambda caps: caps.stats.clean, capability_fn=lambda caps: caps.stats.clean,
value_fn=lambda e: e.area, value_fn=lambda e: e.area,
translation_key="stats_area", translation_key="stats_area",
native_unit_of_measurement=UnitOfArea.SQUARE_METERS, native_unit_of_measurement_fn=get_area_native_unit_of_measurement,
), ),
EcovacsSensorEntityDescription[StatsEvent]( EcovacsSensorEntityDescription[StatsEvent](
key="stats_time", key="stats_time",
@ -85,7 +95,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
value_fn=lambda e: e.area, value_fn=lambda e: e.area,
key="total_stats_area", key="total_stats_area",
translation_key="total_stats_area", translation_key="total_stats_area",
native_unit_of_measurement=UnitOfArea.SQUARE_METERS, native_unit_of_measurement_fn=get_area_native_unit_of_measurement,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
), ),
EcovacsSensorEntityDescription[TotalStatsEvent]( EcovacsSensorEntityDescription[TotalStatsEvent](
@ -249,6 +259,27 @@ class EcovacsSensor(
entity_description: EcovacsSensorEntityDescription entity_description: EcovacsSensorEntityDescription
def __init__(
self,
device: Device,
capability: CapabilityEvent,
entity_description: EcovacsSensorEntityDescription,
**kwargs: Any,
) -> None:
"""Initialize entity."""
super().__init__(device, capability, entity_description, **kwargs)
if (
entity_description.native_unit_of_measurement_fn
and (
native_unit_of_measurement
:= entity_description.native_unit_of_measurement_fn(
device.capabilities.device_type
)
)
is not None
):
self._attr_native_unit_of_measurement = native_unit_of_measurement
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Set up the event listeners now that hass is ready.""" """Set up the event listeners now that hass is ready."""
await super().async_added_to_hass() await super().async_added_to_hass()

View File

@ -64,7 +64,7 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
"/ivp/ensemble/generator", "/ivp/ensemble/generator",
"/ivp/meters", "/ivp/meters",
"/ivp/meters/readings", "/ivp/meters/readings",
"/home,", "/home",
] ]
for end_point in end_points: for end_point in end_points:

View File

@ -7,7 +7,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyenphase"], "loggers": ["pyenphase"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pyenphase==1.26.0"], "requirements": ["pyenphase==1.26.1"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_enphase-envoy._tcp.local." "type": "_enphase-envoy._tcp.local."

View File

@ -223,7 +223,6 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
self._states = cast(dict[int, _StateT], entry_data.state[state_type]) self._states = cast(dict[int, _StateT], entry_data.state[state_type])
assert entry_data.device_info is not None assert entry_data.device_info is not None
device_info = entry_data.device_info device_info = entry_data.device_info
self._device_info = device_info
self._on_entry_data_changed() self._on_entry_data_changed()
self._key = entity_info.key self._key = entity_info.key
self._state_type = state_type self._state_type = state_type
@ -311,6 +310,11 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
@callback @callback
def _on_entry_data_changed(self) -> None: def _on_entry_data_changed(self) -> None:
entry_data = self._entry_data entry_data = self._entry_data
# Update the device info since it can change
# when the device is reconnected
if TYPE_CHECKING:
assert entry_data.device_info is not None
self._device_info = entry_data.device_info
self._api_version = entry_data.api_version self._api_version = entry_data.api_version
self._client = entry_data.client self._client = entry_data.client
if self._device_info.has_deep_sleep: if self._device_info.has_deep_sleep:

View File

@ -35,7 +35,7 @@ async def validate_host(
hass: HomeAssistant, host: str hass: HomeAssistant, host: str
) -> tuple[str, FroniusConfigEntryData]: ) -> tuple[str, FroniusConfigEntryData]:
"""Validate the user input allows us to connect.""" """Validate the user input allows us to connect."""
fronius = Fronius(async_get_clientsession(hass), host) fronius = Fronius(async_get_clientsession(hass, verify_ssl=False), host)
try: try:
datalogger_info: dict[str, Any] datalogger_info: dict[str, Any]

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250509.0"] "requirements": ["home-assistant-frontend==20250516.0"]
} }

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google", "documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["googleapiclient"], "loggers": ["googleapiclient"],
"requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.2.0"] "requirements": ["gcal-sync==7.0.1", "oauth2client==4.1.3", "ical==9.2.4"]
} }

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday", "documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["holidays==0.70", "babel==2.15.0"] "requirements": ["holidays==0.72", "babel==2.15.0"]
} }

View File

@ -234,7 +234,7 @@
"consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye", "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye",
"consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye", "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye",
"consumer_products_coffee_maker_program_beverage_hot_water": "Hot water", "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water",
"dishcare_dishwasher_program_pre_rinse": "Pre_rinse", "dishcare_dishwasher_program_pre_rinse": "Pre-rinse",
"dishcare_dishwasher_program_auto_1": "Auto 1", "dishcare_dishwasher_program_auto_1": "Auto 1",
"dishcare_dishwasher_program_auto_2": "Auto 2", "dishcare_dishwasher_program_auto_2": "Auto 2",
"dishcare_dishwasher_program_auto_3": "Auto 3", "dishcare_dishwasher_program_auto_3": "Auto 3",
@ -252,7 +252,7 @@
"dishcare_dishwasher_program_intensiv_power": "Intensive power", "dishcare_dishwasher_program_intensiv_power": "Intensive power",
"dishcare_dishwasher_program_magic_daily": "Magic daily", "dishcare_dishwasher_program_magic_daily": "Magic daily",
"dishcare_dishwasher_program_super_60": "Super 60ºC", "dishcare_dishwasher_program_super_60": "Super 60ºC",
"dishcare_dishwasher_program_kurz_60": "Kurz 60ºC", "dishcare_dishwasher_program_kurz_60": "Speed 60ºC",
"dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC", "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC",
"dishcare_dishwasher_program_machine_care": "Machine care", "dishcare_dishwasher_program_machine_care": "Machine care",
"dishcare_dishwasher_program_steam_fresh": "Steam fresh", "dishcare_dishwasher_program_steam_fresh": "Steam fresh",

View File

@ -90,16 +90,17 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
minor_version=2, minor_version=2,
) )
if config_entry.minor_version == 2: if config_entry.minor_version <= 3:
# Add a `firmware_version` key # Add a `firmware_version` key if it doesn't exist to handle entries created
# with minor version 1.3 where the firmware version was not set.
hass.config_entries.async_update_entry( hass.config_entries.async_update_entry(
config_entry, config_entry,
data={ data={
**config_entry.data, **config_entry.data,
FIRMWARE_VERSION: None, FIRMWARE_VERSION: config_entry.data.get(FIRMWARE_VERSION),
}, },
version=1, version=1,
minor_version=3, minor_version=4,
) )
_LOGGER.debug( _LOGGER.debug(

View File

@ -62,7 +62,7 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
"""Handle a config flow for Home Assistant Yellow.""" """Handle a config flow for Home Assistant Yellow."""
VERSION = 1 VERSION = 1
MINOR_VERSION = 3 MINOR_VERSION = 4
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Instantiate config flow.""" """Instantiate config flow."""
@ -116,6 +116,11 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
if self._probed_firmware_info is not None if self._probed_firmware_info is not None
else ApplicationType.EZSP else ApplicationType.EZSP
).value, ).value,
FIRMWARE_VERSION: (
self._probed_firmware_info.firmware_version
if self._probed_firmware_info is not None
else None
),
}, },
) )

View File

@ -1,8 +1,11 @@
"""Support for HomematicIP Cloud events.""" """Support for HomematicIP Cloud events."""
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from homematicip.base.channel_event import ChannelEvent
from homematicip.base.functionalChannels import FunctionalChannel
from homematicip.device import Device from homematicip.device import Device
from homeassistant.components.event import ( from homeassistant.components.event import (
@ -23,6 +26,9 @@ from .hap import HomematicipHAP
class HmipEventEntityDescription(EventEntityDescription): class HmipEventEntityDescription(EventEntityDescription):
"""Description of a HomematicIP Cloud event.""" """Description of a HomematicIP Cloud event."""
channel_event_types: list[str] | None = None
channel_selector_fn: Callable[[FunctionalChannel], bool] | None = None
EVENT_DESCRIPTIONS = { EVENT_DESCRIPTIONS = {
"doorbell": HmipEventEntityDescription( "doorbell": HmipEventEntityDescription(
@ -30,6 +36,8 @@ EVENT_DESCRIPTIONS = {
translation_key="doorbell", translation_key="doorbell",
device_class=EventDeviceClass.DOORBELL, device_class=EventDeviceClass.DOORBELL,
event_types=["ring"], event_types=["ring"],
channel_event_types=["DOOR_BELL_SENSOR_EVENT"],
channel_selector_fn=lambda channel: channel.channelRole == "DOOR_BELL_INPUT",
), ),
} }
@ -41,24 +49,29 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the HomematicIP cover from a config entry.""" """Set up the HomematicIP cover from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id] hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = []
async_add_entities( entities.extend(
HomematicipDoorBellEvent( HomematicipDoorBellEvent(
hap, hap,
device, device,
channel.index, channel.index,
EVENT_DESCRIPTIONS["doorbell"], description,
) )
for description in EVENT_DESCRIPTIONS.values()
for device in hap.home.devices for device in hap.home.devices
for channel in device.functionalChannels for channel in device.functionalChannels
if channel.channelRole == "DOOR_BELL_INPUT" if description.channel_selector_fn and description.channel_selector_fn(channel)
) )
async_add_entities(entities)
class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity): class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
"""Event class for HomematicIP doorbell events.""" """Event class for HomematicIP doorbell events."""
_attr_device_class = EventDeviceClass.DOORBELL _attr_device_class = EventDeviceClass.DOORBELL
entity_description: HmipEventEntityDescription
def __init__( def __init__(
self, self,
@ -86,9 +99,27 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
@callback @callback
def _async_handle_event(self, *args, **kwargs) -> None: def _async_handle_event(self, *args, **kwargs) -> None:
"""Handle the event fired by the functional channel.""" """Handle the event fired by the functional channel."""
raised_channel_event = self._get_channel_event_from_args(*args)
if not self._should_raise(raised_channel_event):
return
event_types = self.entity_description.event_types event_types = self.entity_description.event_types
if TYPE_CHECKING: if TYPE_CHECKING:
assert event_types is not None assert event_types is not None
self._trigger_event(event_type=event_types[0]) self._trigger_event(event_type=event_types[0])
self.async_write_ha_state() self.async_write_ha_state()
def _should_raise(self, event_type: str) -> bool:
"""Check if the event should be raised."""
if self.entity_description.channel_event_types is None:
return False
return event_type in self.entity_description.channel_event_types
def _get_channel_event_from_args(self, *args) -> str:
"""Get the channel event."""
if isinstance(args[0], ChannelEvent):
return args[0].channelEventType
return ""

View File

@ -110,14 +110,14 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
mower_attributes = self.mower_attributes mower_attributes = self.mower_attributes
if mower_attributes.mower.state in PAUSED_STATES: if mower_attributes.mower.state in PAUSED_STATES:
return LawnMowerActivity.PAUSED return LawnMowerActivity.PAUSED
if mower_attributes.mower.state in MowerStates.IN_OPERATION:
if mower_attributes.mower.activity == MowerActivities.GOING_HOME:
return LawnMowerActivity.RETURNING
return LawnMowerActivity.MOWING
if (mower_attributes.mower.state == "RESTRICTED") or ( if (mower_attributes.mower.state == "RESTRICTED") or (
mower_attributes.mower.activity in DOCKED_ACTIVITIES mower_attributes.mower.activity in DOCKED_ACTIVITIES
): ):
return LawnMowerActivity.DOCKED return LawnMowerActivity.DOCKED
if mower_attributes.mower.state in MowerStates.IN_OPERATION:
if mower_attributes.mower.activity == MowerActivities.GOING_HOME:
return LawnMowerActivity.RETURNING
return LawnMowerActivity.MOWING
return LawnMowerActivity.ERROR return LawnMowerActivity.ERROR
@property @property

View File

@ -58,6 +58,7 @@ class INKBIRDActiveBluetoothProcessorCoordinator(
update_method=self._async_on_update, update_method=self._async_on_update,
needs_poll_method=self._async_needs_poll, needs_poll_method=self._async_needs_poll,
poll_method=self._async_poll_data, poll_method=self._async_poll_data,
connectable=False, # Polling only happens if active scanning is disabled
) )
async def async_init(self) -> None: async def async_init(self) -> None:

View File

@ -37,5 +37,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pylamarzocco"], "loggers": ["pylamarzocco"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pylamarzocco==2.0.1"] "requirements": ["pylamarzocco==2.0.3"]
} }

View File

@ -7,6 +7,6 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["linkplay"], "loggers": ["linkplay"],
"requirements": ["python-linkplay==0.2.4"], "requirements": ["python-linkplay==0.2.5"],
"zeroconf": ["_linkplay._tcp.local."] "zeroconf": ["_linkplay._tcp.local."]
} }

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar", "documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["ical"], "loggers": ["ical"],
"requirements": ["ical==9.2.0"] "requirements": ["ical==9.2.4"]
} }

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo", "documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["ical==9.2.0"] "requirements": ["ical==9.2.4"]
} }

View File

@ -26,7 +26,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
TWO_YEARS = 2 * 365 * 24 TWO_YEARS_DAYS = 2 * 365
class MillDataUpdateCoordinator(DataUpdateCoordinator): class MillDataUpdateCoordinator(DataUpdateCoordinator):
@ -91,7 +91,7 @@ class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator):
if not last_stats or not last_stats.get(statistic_id): if not last_stats or not last_stats.get(statistic_id):
hourly_data = ( hourly_data = (
await self.mill_data_connection.fetch_historic_energy_usage( await self.mill_data_connection.fetch_historic_energy_usage(
dev_id, n_days=TWO_YEARS dev_id, n_days=TWO_YEARS_DAYS
) )
) )
hourly_data = dict(sorted(hourly_data.items(), key=lambda x: x[0])) hourly_data = dict(sorted(hourly_data.items(), key=lambda x: x[0]))

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill", "documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["mill", "mill_local"], "loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.12.3", "mill-local==0.3.0"] "requirements": ["millheater==0.12.5", "mill-local==0.3.0"]
} }

View File

@ -2063,7 +2063,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
entities = [ entities = [
SelectOptionDict( SelectOptionDict(
value=key, value=key,
label=f"{device_name} {component_data.get(CONF_NAME, '-')}" label=f"{device_name} {component_data.get(CONF_NAME, '-') or '-'}"
f" ({component_data[CONF_PLATFORM]})", f" ({component_data[CONF_PLATFORM]})",
) )
for key, component_data in self._subentry_data["components"].items() for key, component_data in self._subentry_data["components"].items()
@ -2295,7 +2295,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
self._component_id = None self._component_id = None
mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME] mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME]
mqtt_items = ", ".join( mqtt_items = ", ".join(
f"{mqtt_device} {component_data.get(CONF_NAME, '-')} ({component_data[CONF_PLATFORM]})" f"{mqtt_device} {component_data.get(CONF_NAME, '-') or '-'} "
f"({component_data[CONF_PLATFORM]})"
for component_data in self._subentry_data["components"].values() for component_data in self._subentry_data["components"].values()
) )
menu_options = [ menu_options = [

View File

@ -150,7 +150,11 @@ class NetgearRouter:
if device_entry.via_device_id is None: if device_entry.via_device_id is None:
continue # do not add the router itself continue # do not add the router itself
device_mac = dict(device_entry.connections)[dr.CONNECTION_NETWORK_MAC] device_mac = dict(device_entry.connections).get(
dr.CONNECTION_NETWORK_MAC
)
if device_mac is None:
continue
self.devices[device_mac] = { self.devices[device_mac] = {
"mac": device_mac, "mac": device_mac,
"name": device_entry.name, "name": device_entry.name,

View File

@ -181,11 +181,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
session = aiohttp.ClientSession(connector=connector) session = aiohttp.ClientSession(connector=connector)
@callback @callback
def _async_close_websession(event: Event) -> None: def _async_close_websession(event: Event | None = None) -> None:
"""Close websession.""" """Close websession."""
session.detach() session.detach()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_websession) entry.async_on_unload(_async_close_websession)
entry.async_on_unload(
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_close_websession)
)
client = OctoprintClient( client = OctoprintClient(
host=entry.data[CONF_HOST], host=entry.data[CONF_HOST],

View File

@ -140,7 +140,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
content.append( content.append(
ResponseInputImageParam( ResponseInputImageParam(
type="input_image", type="input_image",
file_id=filename,
image_url=f"data:{mime_type};base64,{base64_file}", image_url=f"data:{mime_type};base64,{base64_file}",
detail="auto", detail="auto",
) )

View File

@ -32,6 +32,8 @@ from .const import (
PLACEHOLDER_WEBHOOK_URL, PLACEHOLDER_WEBHOOK_URL,
) )
AUTH_TOKEN_URL = "https://intercom.help/plaato/en/articles/5004720-auth_token"
class PlaatoConfigFlow(ConfigFlow, domain=DOMAIN): class PlaatoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handles a Plaato config flow.""" """Handles a Plaato config flow."""
@ -153,7 +155,10 @@ class PlaatoConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="api_method", step_id="api_method",
data_schema=data_schema, data_schema=data_schema,
errors=errors, errors=errors,
description_placeholders={PLACEHOLDER_DEVICE_TYPE: device_type.name}, description_placeholders={
PLACEHOLDER_DEVICE_TYPE: device_type.name,
"auth_token_url": AUTH_TOKEN_URL,
},
) )
async def _get_webhook_id(self): async def _get_webhook_id(self):

View File

@ -11,7 +11,7 @@
}, },
"api_method": { "api_method": {
"title": "Select API method", "title": "Select API method",
"description": "To be able to query the API an `auth_token` is required which can be obtained by following [these](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) instructions\n\n Selected device: **{device_type}** \n\nIf you rather use the built in webhook method (Airlock only) please check the box below and leave Auth Token blank", "description": "To be able to query the API an 'auth token' is required which can be obtained by following [these instructions]({auth_token_url})\n\nSelected device: **{device_type}** \n\nIf you prefer to use the built-in webhook method (Airlock only) please check the box below and leave 'Auth token' blank",
"data": { "data": {
"use_webhook": "Use webhook", "use_webhook": "Use webhook",
"token": "Paste Auth Token here" "token": "Paste Auth Token here"

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["ical"], "loggers": ["ical"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["ical==9.2.0"] "requirements": ["ical==9.2.4"]
} }

View File

@ -364,7 +364,10 @@ def migrate_entity_ids(
devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) devices = dr.async_entries_for_config_entry(device_reg, config_entry_id)
ch_device_ids = {} ch_device_ids = {}
for device in devices: for device in devices:
(device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) for dev_id in device.identifiers:
(device_uid, ch, is_chime) = get_device_uid_and_ch(dev_id, host)
if not device_uid:
continue
if host.api.supported(None, "UID") and device_uid[0] != host.unique_id: if host.api.supported(None, "UID") and device_uid[0] != host.unique_id:
if ch is None: if ch is None:
@ -372,33 +375,67 @@ def migrate_entity_ids(
else: else:
new_device_id = f"{host.unique_id}_{device_uid[1]}" new_device_id = f"{host.unique_id}_{device_uid[1]}"
_LOGGER.debug( _LOGGER.debug(
"Updating Reolink device UID from %s to %s", device_uid, new_device_id "Updating Reolink device UID from %s to %s",
device_uid,
new_device_id,
) )
new_identifiers = {(DOMAIN, new_device_id)} new_identifiers = {(DOMAIN, new_device_id)}
device_reg.async_update_device(device.id, new_identifiers=new_identifiers) device_reg.async_update_device(
device.id, new_identifiers=new_identifiers
)
if ch is None or is_chime: if ch is None or is_chime:
continue # Do not consider the NVR itself or chimes continue # Do not consider the NVR itself or chimes
# Check for wrongfully combined host with NVR entities in one device
# Can be removed in HA 2025.12
if (DOMAIN, host.unique_id) in device.identifiers:
new_identifiers = device.identifiers.copy()
for old_id in device.identifiers:
if old_id[0] == DOMAIN and old_id[1] != host.unique_id:
new_identifiers.remove(old_id)
_LOGGER.debug(
"Updating Reolink device identifiers from %s to %s",
device.identifiers,
new_identifiers,
)
device_reg.async_update_device(
device.id, new_identifiers=new_identifiers
)
break
# Check for wrongfully added MAC of the NVR/Hub to the camera # Check for wrongfully added MAC of the NVR/Hub to the camera
# Can be removed in HA 2025.12 # Can be removed in HA 2025.12
host_connnection = (CONNECTION_NETWORK_MAC, host.api.mac_address) host_connnection = (CONNECTION_NETWORK_MAC, host.api.mac_address)
if host_connnection in device.connections: if host_connnection in device.connections:
new_connections = device.connections.copy() new_connections = device.connections.copy()
new_connections.remove(host_connnection) new_connections.remove(host_connnection)
device_reg.async_update_device(device.id, new_connections=new_connections) _LOGGER.debug(
"Updating Reolink device connections from %s to %s",
device.connections,
new_connections,
)
device_reg.async_update_device(
device.id, new_connections=new_connections
)
ch_device_ids[device.id] = ch ch_device_ids[device.id] = ch
if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch): if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(
ch
):
if host.api.supported(None, "UID"): if host.api.supported(None, "UID"):
new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}" new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}"
else: else:
new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}" new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}"
_LOGGER.debug( _LOGGER.debug(
"Updating Reolink device UID from %s to %s", device_uid, new_device_id "Updating Reolink device UID from %s to %s",
device_uid,
new_device_id,
) )
new_identifiers = {(DOMAIN, new_device_id)} new_identifiers = {(DOMAIN, new_device_id)}
existing_device = device_reg.async_get_device(identifiers=new_identifiers) existing_device = device_reg.async_get_device(
identifiers=new_identifiers
)
if existing_device is None: if existing_device is None:
device_reg.async_update_device( device_reg.async_update_device(
device.id, new_identifiers=new_identifiers device.id, new_identifiers=new_identifiers

View File

@ -198,7 +198,14 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
return super().available and self._host.api.camera_online(self._channel) if self.entity_description.always_available:
return True
return (
super().available
and self._host.api.camera_online(self._channel)
and not self._host.api.baichuan.privacy_mode(self._channel)
)
def register_callback(self, callback_id: str, cmd_id: int) -> None: def register_callback(self, callback_id: str, cmd_id: int) -> None:
"""Register callback for TCP push events.""" """Register callback for TCP push events."""

View File

@ -465,8 +465,9 @@ class ReolinkHost:
wake = True wake = True
self.last_wake = time() self.last_wake = time()
if self._api.baichuan.privacy_mode(): for channel in self._api.channels:
await self._api.baichuan.get_privacy_mode() if self._api.baichuan.privacy_mode(channel):
await self._api.baichuan.get_privacy_mode(channel)
if self._api.baichuan.privacy_mode(): if self._api.baichuan.privacy_mode():
return # API is shutdown, no need to check states return # API is shutdown, no need to check states
@ -580,7 +581,12 @@ class ReolinkHost:
) )
return return
try:
await self._api.subscribe(self._webhook_url) await self._api.subscribe(self._webhook_url)
except NotSupportedError as err:
self._onvif_push_supported = False
_LOGGER.debug(err)
return
_LOGGER.debug( _LOGGER.debug(
"Host %s: subscribed successfully to webhook %s", "Host %s: subscribed successfully to webhook %s",
@ -601,7 +607,11 @@ class ReolinkHost:
return # API is shutdown, no need to subscribe return # API is shutdown, no need to subscribe
try: try:
if self._onvif_push_supported and not self._api.baichuan.events_active: if (
self._onvif_push_supported
and not self._api.baichuan.events_active
and self._cancel_tcp_push_check is None
):
await self._renew(SubType.push) await self._renew(SubType.push)
if self._onvif_long_poll_supported and self._long_poll_task is not None: if self._onvif_long_poll_supported and self._long_poll_task is not None:

View File

@ -19,5 +19,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["reolink_aio"], "loggers": ["reolink_aio"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["reolink-aio==0.13.2"] "requirements": ["reolink-aio==0.13.3"]
} }

View File

@ -76,13 +76,18 @@ def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]:
def get_device_uid_and_ch( def get_device_uid_and_ch(
device: dr.DeviceEntry, host: ReolinkHost device: dr.DeviceEntry | tuple[str, str], host: ReolinkHost
) -> tuple[list[str], int | None, bool]: ) -> tuple[list[str], int | None, bool]:
"""Get the channel and the split device_uid from a reolink DeviceEntry.""" """Get the channel and the split device_uid from a reolink DeviceEntry."""
device_uid = [] device_uid = []
is_chime = False is_chime = False
for dev_id in device.identifiers: if isinstance(device, dr.DeviceEntry):
dev_ids = device.identifiers
else:
dev_ids = {device}
for dev_id in dev_ids:
if dev_id[0] == DOMAIN: if dev_id[0] == DOMAIN:
device_uid = dev_id[1].split("_") device_uid = dev_id[1].split("_")
if device_uid[0] == host.unique_id: if device_uid[0] == host.unique_id:

View File

@ -28,7 +28,7 @@ from roborock.version_a01_apis import RoborockClientA01
from roborock.web_api import RoborockApiClient from roborock.web_api import RoborockApiClient
from vacuum_map_parser_base.config.color import ColorsPalette from vacuum_map_parser_base.config.color import ColorsPalette
from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.config.image_config import ImageConfig
from vacuum_map_parser_base.config.size import Sizes from vacuum_map_parser_base.config.size import Size, Sizes
from vacuum_map_parser_base.map_data import MapData from vacuum_map_parser_base.map_data import MapData
from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
@ -148,7 +148,13 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
] ]
self.map_parser = RoborockMapDataParser( self.map_parser = RoborockMapDataParser(
ColorsPalette(), ColorsPalette(),
Sizes({k: v * MAP_SCALE for k, v in Sizes.SIZES.items()}), Sizes(
{
k: v * MAP_SCALE
for k, v in Sizes.SIZES.items()
if k != Size.MOP_PATH_WIDTH
}
),
drawables, drawables,
ImageConfig(scale=MAP_SCALE), ImageConfig(scale=MAP_SCALE),
[], [],

View File

@ -252,7 +252,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
return features return features
@property @property
def current_humidity(self) -> int | None: def current_humidity(self) -> float | None:
"""Return the current humidity.""" """Return the current humidity."""
return self.device_data.humidity return self.device_data.humidity

View File

@ -15,5 +15,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pysensibo"], "loggers": ["pysensibo"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pysensibo==1.1.0"] "requirements": ["pysensibo==1.2.1"]
} }

View File

@ -101,14 +101,25 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionSensorEntityDescription, ...] = (
value_fn=lambda data: data.temperature, value_fn=lambda data: data.temperature,
), ),
) )
def _pure_aqi(pm25_pure: PureAQI | None) -> str | None:
"""Return the Pure aqi name or None if unknown."""
if pm25_pure:
aqi_name = pm25_pure.name.lower()
if aqi_name != "unknown":
return aqi_name
return None
PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = (
SensiboDeviceSensorEntityDescription( SensiboDeviceSensorEntityDescription(
key="pm25", key="pm25",
translation_key="pm25_pure", translation_key="pm25_pure",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.pm25_pure.name.lower() if data.pm25_pure else None, value_fn=lambda data: _pure_aqi(data.pm25_pure),
extra_fn=None, extra_fn=None,
options=[aqi.name.lower() for aqi in PureAQI], options=[aqi.name.lower() for aqi in PureAQI if aqi.name != "UNKNOWN"],
), ),
SensiboDeviceSensorEntityDescription( SensiboDeviceSensorEntityDescription(
key="pure_sensitivity", key="pure_sensitivity",
@ -119,6 +130,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = (
FILTER_LAST_RESET_DESCRIPTION, FILTER_LAST_RESET_DESCRIPTION,
) )
DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = (
SensiboDeviceSensorEntityDescription( SensiboDeviceSensorEntityDescription(
key="timer_time", key="timer_time",

View File

@ -13,6 +13,7 @@ from aiohttp import ClientResponseError
from pysmartthings import ( from pysmartthings import (
Attribute, Attribute,
Capability, Capability,
Category,
ComponentStatus, ComponentStatus,
Device, Device,
DeviceEvent, DeviceEvent,
@ -32,6 +33,7 @@ from homeassistant.const import (
ATTR_HW_VERSION, ATTR_HW_VERSION,
ATTR_MANUFACTURER, ATTR_MANUFACTURER,
ATTR_MODEL, ATTR_MODEL,
ATTR_SUGGESTED_AREA,
ATTR_SW_VERSION, ATTR_SW_VERSION,
ATTR_VIA_DEVICE, ATTR_VIA_DEVICE,
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
@ -193,6 +195,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry)
} }
devices = await client.get_devices() devices = await client.get_devices()
for device in devices: for device in devices:
if (
(main_component := device.components.get(MAIN)) is not None
and main_component.manufacturer_category is Category.BLUETOOTH_TRACKER
):
device_status[device.device_id] = FullDevice(
device=device,
status={},
online=True,
)
continue
status = process_status(await client.get_device_status(device.device_id)) status = process_status(await client.get_device_status(device.device_id))
online = await client.get_device_health(device.device_id) online = await client.get_device_health(device.device_id)
device_status[device.device_id] = FullDevice( device_status[device.device_id] = FullDevice(
@ -453,14 +465,24 @@ def create_devices(
ATTR_SW_VERSION: viper.software_version, ATTR_SW_VERSION: viper.software_version,
} }
) )
if (
device_registry.async_get_device({(DOMAIN, device.device.device_id)})
is None
):
kwargs.update(
{
ATTR_SUGGESTED_AREA: (
rooms.get(device.device.room_id)
if device.device.room_id
else None
)
}
)
device_registry.async_get_or_create( device_registry.async_get_or_create(
config_entry_id=entry.entry_id, config_entry_id=entry.entry_id,
identifiers={(DOMAIN, device.device.device_id)}, identifiers={(DOMAIN, device.device.device_id)},
configuration_url="https://account.smartthings.com", configuration_url="https://account.smartthings.com",
name=device.device.label, name=device.device.label,
suggested_area=(
rooms.get(device.device.room_id) if device.device.room_id else None
),
**kwargs, **kwargs,
) )

View File

@ -31,7 +31,7 @@ from .entity import SmartThingsEntity
ATTR_OPERATION_STATE = "operation_state" ATTR_OPERATION_STATE = "operation_state"
MODE_TO_STATE = { MODE_TO_STATE = {
"auto": HVACMode.HEAT_COOL, "auto": HVACMode.AUTO,
"cool": HVACMode.COOL, "cool": HVACMode.COOL,
"eco": HVACMode.AUTO, "eco": HVACMode.AUTO,
"rush hour": HVACMode.AUTO, "rush hour": HVACMode.AUTO,
@ -40,7 +40,7 @@ MODE_TO_STATE = {
"off": HVACMode.OFF, "off": HVACMode.OFF,
} }
STATE_TO_MODE = { STATE_TO_MODE = {
HVACMode.HEAT_COOL: "auto", HVACMode.AUTO: "auto",
HVACMode.COOL: "cool", HVACMode.COOL: "cool",
HVACMode.HEAT: "heat", HVACMode.HEAT: "heat",
HVACMode.OFF: "off", HVACMode.OFF: "off",
@ -58,7 +58,7 @@ OPERATING_STATE_TO_ACTION = {
} }
AC_MODE_TO_STATE = { AC_MODE_TO_STATE = {
"auto": HVACMode.HEAT_COOL, "auto": HVACMode.AUTO,
"cool": HVACMode.COOL, "cool": HVACMode.COOL,
"dry": HVACMode.DRY, "dry": HVACMode.DRY,
"coolClean": HVACMode.COOL, "coolClean": HVACMode.COOL,
@ -66,10 +66,11 @@ AC_MODE_TO_STATE = {
"heat": HVACMode.HEAT, "heat": HVACMode.HEAT,
"heatClean": HVACMode.HEAT, "heatClean": HVACMode.HEAT,
"fanOnly": HVACMode.FAN_ONLY, "fanOnly": HVACMode.FAN_ONLY,
"fan": HVACMode.FAN_ONLY,
"wind": HVACMode.FAN_ONLY, "wind": HVACMode.FAN_ONLY,
} }
STATE_TO_AC_MODE = { STATE_TO_AC_MODE = {
HVACMode.HEAT_COOL: "auto", HVACMode.AUTO: "auto",
HVACMode.COOL: "cool", HVACMode.COOL: "cool",
HVACMode.DRY: "dry", HVACMode.DRY: "dry",
HVACMode.HEAT: "heat", HVACMode.HEAT: "heat",
@ -88,6 +89,7 @@ FAN_OSCILLATION_TO_SWING = {
} }
WIND = "wind" WIND = "wind"
FAN = "fan"
WINDFREE = "windFree" WINDFREE = "windFree"
UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT}
@ -388,14 +390,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
tasks.append(self.async_turn_on()) tasks.append(self.async_turn_on())
mode = STATE_TO_AC_MODE[hvac_mode] mode = STATE_TO_AC_MODE[hvac_mode]
# If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" mode the AirConditioner new mode has to be "wind" # If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" or "fan" mode the AirConditioner
# The conversion make the mode change working # new mode has to be "wind" or "fan"
# The conversion is made only for device that wrongly has capability "wind" instead "fan_only"
if hvac_mode == HVACMode.FAN_ONLY: if hvac_mode == HVACMode.FAN_ONLY:
if WIND in self.get_attribute_value( for fan_mode in (WIND, FAN):
if fan_mode in self.get_attribute_value(
Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES
): ):
mode = WIND mode = fan_mode
break
tasks.append( tasks.append(
self.execute_device_command( self.execute_device_command(

View File

@ -30,5 +30,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pysmartthings"], "loggers": ["pysmartthings"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pysmartthings==3.2.1"] "requirements": ["pysmartthings==3.2.2"]
} }

View File

@ -584,7 +584,7 @@ CAPABILITY_TO_SENSORS: dict[
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
use_temperature_unit=True, use_temperature_unit=True,
# Set the value to None if it is 0 F (-17 C) # Set the value to None if it is 0 F (-17 C)
value_fn=lambda value: None if value in {0, -17} else value, value_fn=lambda value: None if value in {-17, 0, 1} else value,
) )
] ]
}, },

View File

@ -53,7 +53,6 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
try: try:
if not await self._async_check_auth_required(user_input): if not await self._async_check_auth_required(user_input):
info = await self.client.get_info() info = await self.client.get_info()
self._host = str(info.device_ip)
self._device_name = str(info.hostname) self._device_name = str(info.hostname)
if info.model not in Devices: if info.model not in Devices:
@ -79,7 +78,6 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
try: try:
if not await self._async_check_auth_required(user_input): if not await self._async_check_auth_required(user_input):
info = await self.client.get_info() info = await self.client.get_info()
self._host = str(info.device_ip)
self._device_name = str(info.hostname) self._device_name = str(info.hostname)
if info.model not in Devices: if info.model not in Devices:

View File

@ -7,5 +7,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["snoo"], "loggers": ["snoo"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["python-snoo==0.6.5"] "requirements": ["python-snoo==0.6.6"]
} }

View File

@ -56,7 +56,8 @@
"power": "Power button pressed", "power": "Power button pressed",
"status_requested": "Status requested", "status_requested": "Status requested",
"sticky_white_noise_updated": "Sleepytime sounds updated", "sticky_white_noise_updated": "Sleepytime sounds updated",
"config_change": "Config changed" "config_change": "Config changed",
"restart": "Restart"
} }
} }
} }

View File

@ -9,6 +9,7 @@ from tesla_fleet_api.teslemetry import EnergySite, Vehicle
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
@ -229,7 +230,7 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity):
super().__init__(data.live_coordinator, key) super().__init__(data.live_coordinator, key)
@property @property
def _value(self) -> int: def _value(self) -> StateType:
"""Return a specific wall connector value from coordinator data.""" """Return a specific wall connector value from coordinator data."""
return ( return (
self.coordinator.data.get("wall_connectors", {}) self.coordinator.data.get("wall_connectors", {})

View File

@ -1763,7 +1763,6 @@ class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorE
def _async_update_attrs(self) -> None: def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor.""" """Update the attributes of the sensor."""
if self.exists:
self._attr_native_value = self.entity_description.value_fn(self._value) self._attr_native_value = self.entity_description.value_fn(self._value)

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tibber", "documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["tibber"], "loggers": ["tibber"],
"requirements": ["pyTibber==0.30.8"] "requirements": ["pyTibber==0.31.2"]
} }

View File

@ -7,5 +7,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["holidays"], "loggers": ["holidays"],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["holidays==0.70"] "requirements": ["holidays==0.72"]
} }

View File

@ -419,13 +419,26 @@ class ZHADeviceProxy(EventBase):
@callback @callback
def handle_zha_event(self, zha_event: ZHAEvent) -> None: def handle_zha_event(self, zha_event: ZHAEvent) -> None:
"""Handle a ZHA event.""" """Handle a ZHA event."""
if ATTR_UNIQUE_ID in zha_event.data:
unique_id = zha_event.data[ATTR_UNIQUE_ID]
# Client cluster handler unique IDs in the ZHA lib were disambiguated by
# adding a suffix of `_CLIENT`. Unfortunately, this breaks existing
# automations that match the `unique_id` key. This can be removed in a
# future release with proper notice of a breaking change.
unique_id = unique_id.removesuffix("_CLIENT")
else:
unique_id = zha_event.unique_id
self.gateway_proxy.hass.bus.async_fire( self.gateway_proxy.hass.bus.async_fire(
ZHA_EVENT, ZHA_EVENT,
{ {
ATTR_DEVICE_IEEE: str(zha_event.device_ieee), ATTR_DEVICE_IEEE: str(zha_event.device_ieee),
ATTR_UNIQUE_ID: zha_event.unique_id,
ATTR_DEVICE_ID: self.device_id, ATTR_DEVICE_ID: self.device_id,
**zha_event.data, **zha_event.data,
# The order of these keys is intentional, `zha_event.data` can contain
# a `unique_id` key, which we explicitly replace
ATTR_UNIQUE_ID: unique_id,
}, },
) )

View File

@ -278,6 +278,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# and we'll handle the clean up below. # and we'll handle the clean up below.
await driver_events.setup(driver) await driver_events.setup(driver)
if (old_unique_id := entry.unique_id) is not None and old_unique_id != (
new_unique_id := str(driver.controller.home_id)
):
device_registry = dr.async_get(hass)
controller_model = "Unknown model"
if (
(own_node := driver.controller.own_node)
and (
controller_device_entry := device_registry.async_get_device(
identifiers={get_device_id(driver, own_node)}
)
)
and (model := controller_device_entry.model)
):
controller_model = model
async_create_issue(
hass,
DOMAIN,
f"migrate_unique_id.{entry.entry_id}",
data={
"config_entry_id": entry.entry_id,
"config_entry_title": entry.title,
"controller_model": controller_model,
"new_unique_id": new_unique_id,
"old_unique_id": old_unique_id,
},
is_fixable=True,
severity=IssueSeverity.ERROR,
translation_key="migrate_unique_id",
)
else:
async_delete_issue(hass, DOMAIN, f"migrate_unique_id.{entry.entry_id}")
# If the listen task is already failed, we need to raise ConfigEntryNotReady # If the listen task is already failed, we need to raise ConfigEntryNotReady
if listen_task.done(): if listen_task.done():
listen_error, error_message = _get_listen_task_error(listen_task) listen_error, error_message = _get_listen_task_error(listen_task)

View File

@ -71,6 +71,7 @@ from homeassistant.components.websocket_api import (
ActiveConnection, ActiveConnection,
) )
from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -88,13 +89,16 @@ from .const import (
DATA_CLIENT, DATA_CLIENT,
DOMAIN, DOMAIN,
EVENT_DEVICE_ADDED_TO_REGISTRY, EVENT_DEVICE_ADDED_TO_REGISTRY,
LOGGER,
RESTORE_NVM_DRIVER_READY_TIMEOUT, RESTORE_NVM_DRIVER_READY_TIMEOUT,
USER_AGENT, USER_AGENT,
) )
from .helpers import ( from .helpers import (
CannotConnect,
async_enable_statistics, async_enable_statistics,
async_get_node_from_device_id, async_get_node_from_device_id,
async_get_provisioning_entry_from_device_id, async_get_provisioning_entry_from_device_id,
async_get_version_info,
get_device_id, get_device_id,
) )
@ -2857,6 +2861,25 @@ async def websocket_hard_reset_controller(
async with asyncio.timeout(HARD_RESET_CONTROLLER_DRIVER_READY_TIMEOUT): async with asyncio.timeout(HARD_RESET_CONTROLLER_DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait() await wait_driver_ready.wait()
# When resetting the controller, the controller home id is also changed.
# The controller state in the client is stale after resetting the controller,
# so get the new home id with a new client using the helper function.
# The client state will be refreshed by reloading the config entry,
# after the unique id of the config entry has been updated.
try:
version_info = await async_get_version_info(hass, entry.data[CONF_URL])
except CannotConnect:
# Just log this error, as there's nothing to do about it here.
# The stale unique id needs to be handled by a repair flow,
# after the config entry has been reloaded.
LOGGER.error(
"Failed to get server version, cannot update config entry"
"unique id with new home id, after controller reset"
)
else:
hass.config_entries.async_update_entry(
entry, unique_id=str(version_info.home_id)
)
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)

View File

@ -9,14 +9,13 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import aiohttp
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from serial.tools import list_ports from serial.tools import list_ports
import voluptuous as vol import voluptuous as vol
from zwave_js_server.client import Client from zwave_js_server.client import Client
from zwave_js_server.exceptions import FailedCommand from zwave_js_server.exceptions import FailedCommand
from zwave_js_server.model.driver import Driver from zwave_js_server.model.driver import Driver
from zwave_js_server.version import VersionInfo, get_server_version from zwave_js_server.version import VersionInfo
from homeassistant.components import usb from homeassistant.components import usb
from homeassistant.components.hassio import ( from homeassistant.components.hassio import (
@ -36,7 +35,6 @@ from homeassistant.const import CONF_NAME, CONF_URL
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo
@ -69,6 +67,7 @@ from .const import (
DOMAIN, DOMAIN,
RESTORE_NVM_DRIVER_READY_TIMEOUT, RESTORE_NVM_DRIVER_READY_TIMEOUT,
) )
from .helpers import CannotConnect, async_get_version_info
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -79,7 +78,6 @@ ADDON_SETUP_TIMEOUT = 5
ADDON_SETUP_TIMEOUT_ROUNDS = 40 ADDON_SETUP_TIMEOUT_ROUNDS = 40
CONF_EMULATE_HARDWARE = "emulate_hardware" CONF_EMULATE_HARDWARE = "emulate_hardware"
CONF_LOG_LEVEL = "log_level" CONF_LOG_LEVEL = "log_level"
SERVER_VERSION_TIMEOUT = 10
ADDON_LOG_LEVELS = { ADDON_LOG_LEVELS = {
"error": "Error", "error": "Error",
@ -130,22 +128,6 @@ async def validate_input(hass: HomeAssistant, user_input: dict) -> VersionInfo:
raise InvalidInput("cannot_connect") from err raise InvalidInput("cannot_connect") from err
async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> VersionInfo:
"""Return Z-Wave JS version info."""
try:
async with asyncio.timeout(SERVER_VERSION_TIMEOUT):
version_info: VersionInfo = await get_server_version(
ws_address, async_get_clientsession(hass)
)
except (TimeoutError, aiohttp.ClientError) as err:
# We don't want to spam the log if the add-on isn't started
# or takes a long time to start.
_LOGGER.debug("Failed to connect to Z-Wave JS server: %s", err)
raise CannotConnect from err
return version_info
def get_usb_ports() -> dict[str, str]: def get_usb_ports() -> dict[str, str]:
"""Return a dict of USB ports and their friendly names.""" """Return a dict of USB ports and their friendly names."""
ports = list_ports.comports() ports = list_ports.comports()
@ -1357,10 +1339,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
return client.driver return client.driver
class CannotConnect(HomeAssistantError):
"""Indicate connection error."""
class InvalidInput(HomeAssistantError): class InvalidInput(HomeAssistantError):
"""Error to indicate input data is invalid.""" """Error to indicate input data is invalid."""

View File

@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Callable from collections.abc import Callable
from dataclasses import astuple, dataclass from dataclasses import astuple, dataclass
import logging import logging
from typing import Any, cast from typing import Any, cast
import aiohttp
import voluptuous as vol import voluptuous as vol
from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import ( from zwave_js_server.const import (
@ -25,6 +27,7 @@ from zwave_js_server.model.value import (
ValueDataType, ValueDataType,
get_value_id_str, get_value_id_str,
) )
from zwave_js_server.version import VersionInfo, get_server_version
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.config_entries import ConfigEntry, ConfigEntryState
@ -38,6 +41,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.group import expand_entity_ids from homeassistant.helpers.group import expand_entity_ids
from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.helpers.typing import ConfigType, VolSchemaType
@ -54,6 +58,8 @@ from .const import (
LOGGER, LOGGER,
) )
SERVER_VERSION_TIMEOUT = 10
@dataclass @dataclass
class ZwaveValueID: class ZwaveValueID:
@ -568,3 +574,23 @@ def get_network_identifier_for_notification(
return f"`{config_entry.title}`, with the home ID `{home_id}`," return f"`{config_entry.title}`, with the home ID `{home_id}`,"
return f"with the home ID `{home_id}`" return f"with the home ID `{home_id}`"
return "" return ""
async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> VersionInfo:
"""Return Z-Wave JS version info."""
try:
async with asyncio.timeout(SERVER_VERSION_TIMEOUT):
version_info: VersionInfo = await get_server_version(
ws_address, async_get_clientsession(hass)
)
except (TimeoutError, aiohttp.ClientError) as err:
# We don't want to spam the log if the add-on isn't started
# or takes a long time to start.
LOGGER.debug("Failed to connect to Z-Wave JS server: %s", err)
raise CannotConnect from err
return version_info
class CannotConnect(HomeAssistantError):
"""Indicate connection error."""

View File

@ -57,6 +57,47 @@ class DeviceConfigFileChangedFlow(RepairsFlow):
) )
class MigrateUniqueIDFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
def __init__(self, data: dict[str, str]) -> None:
"""Initialize."""
self.description_placeholders: dict[str, str] = {
"config_entry_title": data["config_entry_title"],
"controller_model": data["controller_model"],
"new_unique_id": data["new_unique_id"],
"old_unique_id": data["old_unique_id"],
}
self._config_entry_id: str = data["config_entry_id"]
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
config_entry = self.hass.config_entries.async_get_entry(
self._config_entry_id
)
# If config entry was removed, we can ignore the issue.
if config_entry is not None:
self.hass.config_entries.async_update_entry(
config_entry,
unique_id=self.description_placeholders["new_unique_id"],
)
return self.async_create_entry(data={})
return self.async_show_form(
step_id="confirm",
description_placeholders=self.description_placeholders,
)
async def async_create_fix_flow( async def async_create_fix_flow(
hass: HomeAssistant, issue_id: str, data: dict[str, str] | None hass: HomeAssistant, issue_id: str, data: dict[str, str] | None
) -> RepairsFlow: ) -> RepairsFlow:
@ -65,4 +106,7 @@ async def async_create_fix_flow(
if issue_id.split(".")[0] == "device_config_file_changed": if issue_id.split(".")[0] == "device_config_file_changed":
assert data assert data
return DeviceConfigFileChangedFlow(data) return DeviceConfigFileChangedFlow(data)
if issue_id.split(".")[0] == "migrate_unique_id":
assert data
return MigrateUniqueIDFlow(data)
return ConfirmRepairFlow() return ConfirmRepairFlow()

View File

@ -273,6 +273,17 @@
"invalid_server_version": { "invalid_server_version": {
"description": "The version of Z-Wave Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave Server to the latest version to fix this issue.", "description": "The version of Z-Wave Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave Server to the latest version to fix this issue.",
"title": "Newer version of Z-Wave Server needed" "title": "Newer version of Z-Wave Server needed"
},
"migrate_unique_id": {
"fix_flow": {
"step": {
"confirm": {
"description": "A Z-Wave controller of model {controller_model} with a different ID ({new_unique_id}) than the previously connected controller ({old_unique_id}) was connected to the {config_entry_title} configuration entry.\n\nReasons for a different controller ID could be:\n\n1. The controller was factory reset, with a 3rd party application.\n2. A controller Non Volatile Memory (NVM) backup was restored to the controller, with a 3rd party application.\n3. A different controller was connected to this configuration entry.\n\nIf a different controller was connected, you should instead set up a new configuration entry for the new controller.\n\nIf you are sure that the current controller is the correct controller you can confirm this by pressing Submit, and the configuration entry will remember the new controller ID.",
"title": "An unknown controller was detected"
}
}
},
"title": "An unknown controller was detected"
} }
}, },
"services": { "services": {

View File

@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025 MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 5 MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "1" PATCH_VERSION: Final = "2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)

View File

@ -1,7 +1,7 @@
# Automatically generated by gen_requirements_all.py, do not edit # Automatically generated by gen_requirements_all.py, do not edit
aiodhcpwatcher==1.1.1 aiodhcpwatcher==1.1.1
aiodiscover==2.6.1 aiodiscover==2.7.0
aiodns==3.4.0 aiodns==3.4.0
aiohasupervisor==0.3.1 aiohasupervisor==0.3.1
aiohttp-asyncmdnsresolver==0.1.1 aiohttp-asyncmdnsresolver==0.1.1
@ -38,7 +38,7 @@ habluetooth==3.48.2
hass-nabucasa==0.96.0 hass-nabucasa==0.96.0
hassil==2.2.3 hassil==2.2.3
home-assistant-bluetooth==1.13.1 home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250509.0 home-assistant-frontend==20250516.0
home-assistant-intents==2025.5.7 home-assistant-intents==2025.5.7
httpx==0.28.1 httpx==0.28.1
ifaddr==0.2.0 ifaddr==0.2.0
@ -70,7 +70,7 @@ typing-extensions>=4.13.0,<5.0
ulid-transform==1.4.0 ulid-transform==1.4.0
urllib3>=1.26.5,<2 urllib3>=1.26.5,<2
uv==0.7.1 uv==0.7.1
voluptuous-openapi==0.0.7 voluptuous-openapi==0.1.0
voluptuous-serialize==2.6.0 voluptuous-serialize==2.6.0
voluptuous==0.15.2 voluptuous==0.15.2
webrtc-models==0.3.0 webrtc-models==0.3.0
@ -217,3 +217,8 @@ aiofiles>=24.1.0
# https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1134
# https://github.com/aio-libs/multidict/issues/1131 # https://github.com/aio-libs/multidict/issues/1131
multidict>=6.4.2 multidict>=6.4.2
# rpds-py > 0.25.0 requires cargo 1.84.0
# Stable Alpine current only ships cargo 1.83.0
# No wheels upstream available for armhf & armv7
rpds-py==0.24.0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2025.5.1" version = "2025.5.2"
license = "Apache-2.0" license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."
@ -120,7 +120,7 @@ dependencies = [
"uv==0.7.1", "uv==0.7.1",
"voluptuous==0.15.2", "voluptuous==0.15.2",
"voluptuous-serialize==2.6.0", "voluptuous-serialize==2.6.0",
"voluptuous-openapi==0.0.7", "voluptuous-openapi==0.1.0",
"yarl==1.20.0", "yarl==1.20.0",
"webrtc-models==0.3.0", "webrtc-models==0.3.0",
"zeroconf==0.147.0", "zeroconf==0.147.0",

2
requirements.txt generated
View File

@ -57,7 +57,7 @@ urllib3>=1.26.5,<2
uv==0.7.1 uv==0.7.1
voluptuous==0.15.2 voluptuous==0.15.2
voluptuous-serialize==2.6.0 voluptuous-serialize==2.6.0
voluptuous-openapi==0.0.7 voluptuous-openapi==0.1.0
yarl==1.20.0 yarl==1.20.0
webrtc-models==0.3.0 webrtc-models==0.3.0
zeroconf==0.147.0 zeroconf==0.147.0

32
requirements_all.txt generated
View File

@ -214,13 +214,13 @@ aiobafi6==0.9.0
aiobotocore==2.21.1 aiobotocore==2.21.1
# homeassistant.components.comelit # homeassistant.components.comelit
aiocomelit==0.12.0 aiocomelit==0.12.1
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodhcpwatcher==1.1.1 aiodhcpwatcher==1.1.1
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodiscover==2.6.1 aiodiscover==2.7.0
# homeassistant.components.dnsip # homeassistant.components.dnsip
aiodns==3.4.0 aiodns==3.4.0
@ -762,7 +762,7 @@ debugpy==1.8.13
# decora==0.6 # decora==0.6
# homeassistant.components.ecovacs # homeassistant.components.ecovacs
deebot-client==13.1.0 deebot-client==13.2.0
# homeassistant.components.ihc # homeassistant.components.ihc
# homeassistant.components.namecheapdns # homeassistant.components.namecheapdns
@ -986,7 +986,7 @@ gardena-bluetooth==1.6.0
gassist-text==0.0.12 gassist-text==0.0.12
# homeassistant.components.google # homeassistant.components.google
gcal-sync==7.0.0 gcal-sync==7.0.1
# homeassistant.components.geniushub # homeassistant.components.geniushub
geniushub-client==0.7.1 geniushub-client==0.7.1
@ -1158,10 +1158,10 @@ hole==0.8.0
# homeassistant.components.holiday # homeassistant.components.holiday
# homeassistant.components.workday # homeassistant.components.workday
holidays==0.70 holidays==0.72
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20250509.0 home-assistant-frontend==20250516.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.5.7 home-assistant-intents==2025.5.7
@ -1200,7 +1200,7 @@ ibmiotf==0.3.4
# homeassistant.components.local_calendar # homeassistant.components.local_calendar
# homeassistant.components.local_todo # homeassistant.components.local_todo
# homeassistant.components.remote_calendar # homeassistant.components.remote_calendar
ical==9.2.0 ical==9.2.4
# homeassistant.components.caldav # homeassistant.components.caldav
icalendar==6.1.0 icalendar==6.1.0
@ -1427,7 +1427,7 @@ microBeesPy==0.3.5
mill-local==0.3.0 mill-local==0.3.0
# homeassistant.components.mill # homeassistant.components.mill
millheater==0.12.3 millheater==0.12.5
# homeassistant.components.minio # homeassistant.components.minio
minio==7.1.12 minio==7.1.12
@ -1804,7 +1804,7 @@ pyRFXtrx==0.31.1
pySDCP==1 pySDCP==1
# homeassistant.components.tibber # homeassistant.components.tibber
pyTibber==0.30.8 pyTibber==0.31.2
# homeassistant.components.dlink # homeassistant.components.dlink
pyW215==0.7.0 pyW215==0.7.0
@ -1955,7 +1955,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1 pyemoncms==0.1.1
# homeassistant.components.enphase_envoy # homeassistant.components.enphase_envoy
pyenphase==1.26.0 pyenphase==1.26.1
# homeassistant.components.envisalink # homeassistant.components.envisalink
pyenvisalink==4.7 pyenvisalink==4.7
@ -2093,7 +2093,7 @@ pykwb==0.0.8
pylacrosse==0.4 pylacrosse==0.4
# homeassistant.components.lamarzocco # homeassistant.components.lamarzocco
pylamarzocco==2.0.1 pylamarzocco==2.0.3
# homeassistant.components.lastfm # homeassistant.components.lastfm
pylast==5.1.0 pylast==5.1.0
@ -2293,7 +2293,7 @@ pysaj==0.0.16
pyschlage==2025.4.0 pyschlage==2025.4.0
# homeassistant.components.sensibo # homeassistant.components.sensibo
pysensibo==1.1.0 pysensibo==1.2.1
# homeassistant.components.serial # homeassistant.components.serial
pyserial-asyncio-fast==0.16 pyserial-asyncio-fast==0.16
@ -2326,7 +2326,7 @@ pysma==0.7.5
pysmappee==0.2.29 pysmappee==0.2.29
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartthings==3.2.1 pysmartthings==3.2.2
# homeassistant.components.smarty # homeassistant.components.smarty
pysmarty2==0.10.2 pysmarty2==0.10.2
@ -2437,7 +2437,7 @@ python-juicenet==1.1.0
python-kasa[speedups]==0.10.2 python-kasa[speedups]==0.10.2
# homeassistant.components.linkplay # homeassistant.components.linkplay
python-linkplay==0.2.4 python-linkplay==0.2.5
# homeassistant.components.lirc # homeassistant.components.lirc
# python-lirc==1.2.3 # python-lirc==1.2.3
@ -2486,7 +2486,7 @@ python-roborock==2.18.2
python-smarttub==0.0.39 python-smarttub==0.0.39
# homeassistant.components.snoo # homeassistant.components.snoo
python-snoo==0.6.5 python-snoo==0.6.6
# homeassistant.components.songpal # homeassistant.components.songpal
python-songpal==0.16.2 python-songpal==0.16.2
@ -2637,7 +2637,7 @@ renault-api==0.3.1
renson-endura-delta==1.7.2 renson-endura-delta==1.7.2
# homeassistant.components.reolink # homeassistant.components.reolink
reolink-aio==0.13.2 reolink-aio==0.13.3
# homeassistant.components.idteck_prox # homeassistant.components.idteck_prox
rfk101py==0.0.1 rfk101py==0.0.1

View File

@ -202,13 +202,13 @@ aiobafi6==0.9.0
aiobotocore==2.21.1 aiobotocore==2.21.1
# homeassistant.components.comelit # homeassistant.components.comelit
aiocomelit==0.12.0 aiocomelit==0.12.1
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodhcpwatcher==1.1.1 aiodhcpwatcher==1.1.1
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodiscover==2.6.1 aiodiscover==2.7.0
# homeassistant.components.dnsip # homeassistant.components.dnsip
aiodns==3.4.0 aiodns==3.4.0
@ -653,7 +653,7 @@ dbus-fast==2.43.0
debugpy==1.8.13 debugpy==1.8.13
# homeassistant.components.ecovacs # homeassistant.components.ecovacs
deebot-client==13.1.0 deebot-client==13.2.0
# homeassistant.components.ihc # homeassistant.components.ihc
# homeassistant.components.namecheapdns # homeassistant.components.namecheapdns
@ -840,7 +840,7 @@ gardena-bluetooth==1.6.0
gassist-text==0.0.12 gassist-text==0.0.12
# homeassistant.components.google # homeassistant.components.google
gcal-sync==7.0.0 gcal-sync==7.0.1
# homeassistant.components.geniushub # homeassistant.components.geniushub
geniushub-client==0.7.1 geniushub-client==0.7.1
@ -988,10 +988,10 @@ hole==0.8.0
# homeassistant.components.holiday # homeassistant.components.holiday
# homeassistant.components.workday # homeassistant.components.workday
holidays==0.70 holidays==0.72
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20250509.0 home-assistant-frontend==20250516.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.5.7 home-assistant-intents==2025.5.7
@ -1021,7 +1021,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.local_calendar # homeassistant.components.local_calendar
# homeassistant.components.local_todo # homeassistant.components.local_todo
# homeassistant.components.remote_calendar # homeassistant.components.remote_calendar
ical==9.2.0 ical==9.2.4
# homeassistant.components.caldav # homeassistant.components.caldav
icalendar==6.1.0 icalendar==6.1.0
@ -1200,7 +1200,7 @@ microBeesPy==0.3.5
mill-local==0.3.0 mill-local==0.3.0
# homeassistant.components.mill # homeassistant.components.mill
millheater==0.12.3 millheater==0.12.5
# homeassistant.components.minio # homeassistant.components.minio
minio==7.1.12 minio==7.1.12
@ -1491,7 +1491,7 @@ pyHomee==1.2.8
pyRFXtrx==0.31.1 pyRFXtrx==0.31.1
# homeassistant.components.tibber # homeassistant.components.tibber
pyTibber==0.30.8 pyTibber==0.31.2
# homeassistant.components.dlink # homeassistant.components.dlink
pyW215==0.7.0 pyW215==0.7.0
@ -1600,7 +1600,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1 pyemoncms==0.1.1
# homeassistant.components.enphase_envoy # homeassistant.components.enphase_envoy
pyenphase==1.26.0 pyenphase==1.26.1
# homeassistant.components.everlights # homeassistant.components.everlights
pyeverlights==0.1.0 pyeverlights==0.1.0
@ -1708,7 +1708,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.8 pykulersky==0.5.8
# homeassistant.components.lamarzocco # homeassistant.components.lamarzocco
pylamarzocco==2.0.1 pylamarzocco==2.0.3
# homeassistant.components.lastfm # homeassistant.components.lastfm
pylast==5.1.0 pylast==5.1.0
@ -1875,7 +1875,7 @@ pysabnzbd==1.1.1
pyschlage==2025.4.0 pyschlage==2025.4.0
# homeassistant.components.sensibo # homeassistant.components.sensibo
pysensibo==1.1.0 pysensibo==1.2.1
# homeassistant.components.acer_projector # homeassistant.components.acer_projector
# homeassistant.components.crownstone # homeassistant.components.crownstone
@ -1899,7 +1899,7 @@ pysma==0.7.5
pysmappee==0.2.29 pysmappee==0.2.29
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartthings==3.2.1 pysmartthings==3.2.2
# homeassistant.components.smarty # homeassistant.components.smarty
pysmarty2==0.10.2 pysmarty2==0.10.2
@ -1980,7 +1980,7 @@ python-juicenet==1.1.0
python-kasa[speedups]==0.10.2 python-kasa[speedups]==0.10.2
# homeassistant.components.linkplay # homeassistant.components.linkplay
python-linkplay==0.2.4 python-linkplay==0.2.5
# homeassistant.components.matter # homeassistant.components.matter
python-matter-server==7.0.0 python-matter-server==7.0.0
@ -2023,7 +2023,7 @@ python-roborock==2.18.2
python-smarttub==0.0.39 python-smarttub==0.0.39
# homeassistant.components.snoo # homeassistant.components.snoo
python-snoo==0.6.5 python-snoo==0.6.6
# homeassistant.components.songpal # homeassistant.components.songpal
python-songpal==0.16.2 python-songpal==0.16.2
@ -2144,7 +2144,7 @@ renault-api==0.3.1
renson-endura-delta==1.7.2 renson-endura-delta==1.7.2
# homeassistant.components.reolink # homeassistant.components.reolink
reolink-aio==0.13.2 reolink-aio==0.13.3
# homeassistant.components.rflink # homeassistant.components.rflink
rflink==0.0.66 rflink==0.0.66

View File

@ -246,6 +246,11 @@ aiofiles>=24.1.0
# https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1134
# https://github.com/aio-libs/multidict/issues/1131 # https://github.com/aio-libs/multidict/issues/1131
multidict>=6.4.2 multidict>=6.4.2
# rpds-py > 0.25.0 requires cargo 1.84.0
# Stable Alpine current only ships cargo 1.83.0
# No wheels upstream available for armhf & armv7
rpds-py==0.24.0
""" """
GENERATED_MESSAGE = ( GENERATED_MESSAGE = (

View File

@ -48,7 +48,7 @@
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'current_temperature': 22.1, 'current_temperature': 22.1,
'friendly_name': 'Climate0', 'friendly_name': 'Climate0',
'hvac_action': <HVACAction.HEATING: 'heating'>, 'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([ 'hvac_modes': list([
<HVACMode.AUTO: 'auto'>, <HVACMode.AUTO: 'auto'>,
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,

View File

@ -181,14 +181,14 @@
'supported_features': 0, 'supported_features': 0,
'translation_key': 'stats_area', 'translation_key': 'stats_area',
'unique_id': '8516fbb1-17f1-4194-0000000_stats_area', 'unique_id': '8516fbb1-17f1-4194-0000000_stats_area',
'unit_of_measurement': <UnitOfArea.SQUARE_METERS: 'm²'>, 'unit_of_measurement': <UnitOfArea.SQUARE_CENTIMETERS: 'cm²'>,
}) })
# --- # ---
# name: test_sensors[5xu9h3][sensor.goat_g1_area_cleaned:state] # name: test_sensors[5xu9h3][sensor.goat_g1_area_cleaned:state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'friendly_name': 'Goat G1 Area cleaned', 'friendly_name': 'Goat G1 Area cleaned',
'unit_of_measurement': <UnitOfArea.SQUARE_METERS: 'm²'>, 'unit_of_measurement': <UnitOfArea.SQUARE_CENTIMETERS: 'cm²'>,
}), }),
'context': <ANY>, 'context': <ANY>,
'entity_id': 'sensor.goat_g1_area_cleaned', 'entity_id': 'sensor.goat_g1_area_cleaned',
@ -523,7 +523,7 @@
'supported_features': 0, 'supported_features': 0,
'translation_key': 'total_stats_area', 'translation_key': 'total_stats_area',
'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_area', 'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_area',
'unit_of_measurement': <UnitOfArea.SQUARE_METERS: 'm²'>, 'unit_of_measurement': <UnitOfArea.SQUARE_CENTIMETERS: 'cm²'>,
}) })
# --- # ---
# name: test_sensors[5xu9h3][sensor.goat_g1_total_area_cleaned:state] # name: test_sensors[5xu9h3][sensor.goat_g1_total_area_cleaned:state]
@ -531,7 +531,7 @@
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'friendly_name': 'Goat G1 Total area cleaned', 'friendly_name': 'Goat G1 Total area cleaned',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>, 'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfArea.SQUARE_METERS: 'm²'>, 'unit_of_measurement': <UnitOfArea.SQUARE_CENTIMETERS: 'cm²'>,
}), }),
'context': <ANY>, 'context': <ANY>,
'entity_id': 'sensor.goat_g1_total_area_cleaned', 'entity_id': 'sensor.goat_g1_total_area_cleaned',

View File

@ -896,8 +896,8 @@
'/api/v1/production/inverters': 'Testing request replies.', '/api/v1/production/inverters': 'Testing request replies.',
'/api/v1/production/inverters_log': '{"headers":{"Hello":"World"},"code":200}', '/api/v1/production/inverters_log': '{"headers":{"Hello":"World"},"code":200}',
'/api/v1/production_log': '{"headers":{"Hello":"World"},"code":200}', '/api/v1/production_log': '{"headers":{"Hello":"World"},"code":200}',
'/home,': 'Testing request replies.', '/home': 'Testing request replies.',
'/home,_log': '{"headers":{"Hello":"World"},"code":200}', '/home_log': '{"headers":{"Hello":"World"},"code":200}',
'/info': 'Testing request replies.', '/info': 'Testing request replies.',
'/info_log': '{"headers":{"Hello":"World"},"code":200}', '/info_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/ensemble/dry_contacts': 'Testing request replies.', '/ivp/ensemble/dry_contacts': 'Testing request replies.',
@ -1390,7 +1390,7 @@
'/api/v1/production_log': dict({ '/api/v1/production_log': dict({
'Error': "EnvoyError('Test')", 'Error': "EnvoyError('Test')",
}), }),
'/home,_log': dict({ '/home_log': dict({
'Error': "EnvoyError('Test')", 'Error': "EnvoyError('Test')",
}), }),
'/info_log': dict({ '/info_log': dict({

View File

@ -1,6 +1,7 @@
"""Test ESPHome binary sensors.""" """Test ESPHome binary sensors."""
import asyncio import asyncio
from dataclasses import asdict
from typing import Any from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@ -8,6 +9,7 @@ from aioesphomeapi import (
APIClient, APIClient,
BinarySensorInfo, BinarySensorInfo,
BinarySensorState, BinarySensorState,
DeviceInfo,
SensorInfo, SensorInfo,
SensorState, SensorState,
build_unique_id, build_unique_id,
@ -665,3 +667,63 @@ async def test_entity_id_preserved_on_upgrade_when_in_storage(
) )
state = hass.states.get("binary_sensor.user_named") state = hass.states.get("binary_sensor.user_named")
assert state is not None assert state is not None
async def test_deep_sleep_added_after_setup(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test deep sleep added after setup."""
mock_device = await mock_esphome_device(
mock_client=mock_client,
entity_info=[
BinarySensorInfo(
object_id="test",
key=1,
name="test",
unique_id="test",
),
],
user_service=[],
states=[
BinarySensorState(key=1, state=True, missing_state=False),
],
device_info={"has_deep_sleep": False},
)
entity_id = "binary_sensor.test_test"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
await mock_device.mock_disconnect(expected_disconnect=True)
# No deep sleep, should be unavailable
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
await mock_device.mock_connect()
# reconnect, should be available
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
await mock_device.mock_disconnect(expected_disconnect=True)
new_device_info = DeviceInfo(
**{**asdict(mock_device.device_info), "has_deep_sleep": True}
)
mock_device.client.device_info = AsyncMock(return_value=new_device_info)
mock_device.device_info = new_device_info
await mock_device.mock_connect()
# Now disconnect that deep sleep is set in device info
await mock_device.mock_disconnect(expected_disconnect=True)
# Deep sleep, should be available
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON

View File

@ -101,12 +101,12 @@ async def test_config_flow(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Home Assistant Yellow" assert result["title"] == "Home Assistant Yellow"
assert result["data"] == {"firmware": "ezsp"} assert result["data"] == {"firmware": "ezsp", "firmware_version": None}
assert result["options"] == {} assert result["options"] == {}
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
config_entry = hass.config_entries.async_entries(DOMAIN)[0] config_entry = hass.config_entries.async_entries(DOMAIN)[0]
assert config_entry.data == {"firmware": "ezsp"} assert config_entry.data == {"firmware": "ezsp", "firmware_version": None}
assert config_entry.options == {} assert config_entry.options == {}
assert config_entry.title == "Home Assistant Yellow" assert config_entry.title == "Home Assistant Yellow"

View File

@ -10,6 +10,9 @@ from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
) )
from homeassistant.components.homeassistant_yellow.config_flow import (
HomeAssistantYellowConfigFlow,
)
from homeassistant.components.homeassistant_yellow.const import DOMAIN from homeassistant.components.homeassistant_yellow.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -248,3 +251,71 @@ async def test_setup_entry_addon_info_fails(
await hass.async_block_till_done() await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
("start_version", "data", "migrated_data"),
[
(1, {}, {"firmware": "ezsp", "firmware_version": None}),
(2, {"firmware": "ezsp"}, {"firmware": "ezsp", "firmware_version": None}),
(
2,
{"firmware": "ezsp", "firmware_version": "123"},
{"firmware": "ezsp", "firmware_version": "123"},
),
(3, {"firmware": "ezsp"}, {"firmware": "ezsp", "firmware_version": None}),
(
3,
{"firmware": "ezsp", "firmware_version": "123"},
{"firmware": "ezsp", "firmware_version": "123"},
),
],
)
async def test_migrate_entry(
hass: HomeAssistant,
start_version: int,
data: dict,
migrated_data: dict,
) -> None:
"""Test migration of a config entry."""
mock_integration(hass, MockModule("hassio"))
await async_setup_component(hass, HASSIO_DOMAIN, {})
# Setup the config entry
config_entry = MockConfigEntry(
data=data,
domain=DOMAIN,
options={},
title="Home Assistant Yellow",
version=1,
minor_version=start_version,
)
config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.homeassistant_yellow.get_os_info",
return_value={"board": "yellow"},
),
patch(
"homeassistant.components.onboarding.async_is_onboarded",
return_value=True,
),
patch(
"homeassistant.components.homeassistant_yellow.guess_firmware_info",
return_value=FirmwareInfo( # Nothing is setup
device="/dev/ttyAMA1",
firmware_version="1234",
firmware_type=ApplicationType.EZSP,
source="unknown",
owners=[],
),
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.data == migrated_data
assert config_entry.options == {}
assert config_entry.minor_version == HomeAssistantYellowConfigFlow.MINOR_VERSION
assert config_entry.version == HomeAssistantYellowConfigFlow.VERSION

View File

@ -35,3 +35,32 @@ async def test_door_bell_event(
ha_state = hass.states.get(entity_id) ha_state = hass.states.get(entity_id)
assert ha_state.state != STATE_UNKNOWN assert ha_state.state != STATE_UNKNOWN
async def test_door_bell_event_wrong_event_type(
hass: HomeAssistant,
default_mock_hap_factory: HomeFactory,
) -> None:
"""Test of door bell event of HmIP-DSD-PCB."""
entity_id = "event.dsdpcb_klingel_doorbell"
entity_name = "dsdpcb_klingel doorbell"
device_model = "HmIP-DSD-PCB"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=["dsdpcb_klingel"]
)
ha_state, hmip_device = get_and_check_entity_basics(
hass, mock_hap, entity_id, entity_name, device_model
)
ch = hmip_device.functionalChannels[1]
channel_event = ChannelEvent(
channelEventType="KEY_PRESS", channelIndex=1, deviceId=ch.device.id
)
assert ha_state.state == STATE_UNKNOWN
ch.fire_channel_event(channel_event)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_UNKNOWN

View File

@ -37,6 +37,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed
MowerStates.IN_OPERATION, MowerStates.IN_OPERATION,
LawnMowerActivity.MOWING, LawnMowerActivity.MOWING,
), ),
(
MowerActivities.PARKED_IN_CS,
MowerStates.IN_OPERATION,
LawnMowerActivity.DOCKED,
),
], ],
) )
async def test_lawn_mower_states( async def test_lawn_mower_states(

View File

@ -324,7 +324,6 @@ async def test_init_error(
"type": "input_image", "type": "input_image",
"image_url": "data:image/jpeg;base64,BASE64IMAGE1", "image_url": "data:image/jpeg;base64,BASE64IMAGE1",
"detail": "auto", "detail": "auto",
"file_id": "/a/b/c.jpg",
}, },
], ],
}, },
@ -349,13 +348,11 @@ async def test_init_error(
"type": "input_image", "type": "input_image",
"image_url": "data:image/jpeg;base64,BASE64IMAGE1", "image_url": "data:image/jpeg;base64,BASE64IMAGE1",
"detail": "auto", "detail": "auto",
"file_id": "/a/b/c.jpg",
}, },
{ {
"type": "input_image", "type": "input_image",
"image_url": "data:image/jpeg;base64,BASE64IMAGE2", "image_url": "data:image/jpeg;base64,BASE64IMAGE2",
"detail": "auto", "detail": "auto",
"file_id": "d/e/f.jpg",
}, },
], ],
}, },

View File

@ -630,7 +630,7 @@ async def test_cleanup_mac_connection(
domain = Platform.SWITCH domain = Platform.SWITCH
dev_entry = device_registry.async_get_or_create( dev_entry = device_registry.async_get_or_create(
identifiers={(DOMAIN, dev_id)}, identifiers={(DOMAIN, dev_id), ("OTHER_INTEGRATION", "SOME_ID")},
connections={(CONNECTION_NETWORK_MAC, TEST_MAC)}, connections={(CONNECTION_NETWORK_MAC, TEST_MAC)},
config_entry_id=config_entry.entry_id, config_entry_id=config_entry.entry_id,
disabled_by=None, disabled_by=None,
@ -664,6 +664,66 @@ async def test_cleanup_mac_connection(
reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM
async def test_cleanup_combined_with_NVR(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test cleanup of the device registry if IPC camera device was combined with the NVR device."""
reolink_connect.channels = [0]
reolink_connect.baichuan.mac_address.return_value = None
entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio"
dev_id = f"{TEST_UID}_{TEST_UID_CAM}"
domain = Platform.SWITCH
start_identifiers = {
(DOMAIN, dev_id),
(DOMAIN, TEST_UID),
("OTHER_INTEGRATION", "SOME_ID"),
}
dev_entry = device_registry.async_get_or_create(
identifiers=start_identifiers,
connections={(CONNECTION_NETWORK_MAC, TEST_MAC)},
config_entry_id=config_entry.entry_id,
disabled_by=None,
)
entity_registry.async_get_or_create(
domain=domain,
platform=DOMAIN,
unique_id=entity_id,
config_entry=config_entry,
suggested_object_id=entity_id,
disabled_by=None,
device_id=dev_entry.id,
)
assert entity_registry.async_get_entity_id(domain, DOMAIN, entity_id)
device = device_registry.async_get_device(identifiers={(DOMAIN, dev_id)})
assert device
assert device.identifiers == start_identifiers
# setup CH 0 and host entities/device
with patch("homeassistant.components.reolink.PLATFORMS", [domain]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert entity_registry.async_get_entity_id(domain, DOMAIN, entity_id)
device = device_registry.async_get_device(identifiers={(DOMAIN, dev_id)})
assert device
assert device.identifiers == {(DOMAIN, dev_id)}
host_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_UID)})
assert host_device
assert host_device.identifiers == {
(DOMAIN, TEST_UID),
("OTHER_INTEGRATION", "SOME_ID"),
}
reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM
async def test_no_repair_issue( async def test_no_repair_issue(
hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry
) -> None: ) -> None:

View File

@ -11,7 +11,7 @@ import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@ -45,3 +45,14 @@ async def test_sensor(
state = hass.states.get("sensor.kitchen_pure_aqi") state = hass.states.get("sensor.kitchen_pure_aqi")
assert state.state == "moderate" assert state.state == "moderate"
mock_client.async_get_devices_data.return_value.parsed[
"AAZZAAZZ"
].pm25_pure = PureAQI(0)
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.kitchen_pure_aqi")
assert state.state == STATE_UNKNOWN

View File

@ -150,6 +150,7 @@ def mock_smartthings() -> Generator[AsyncMock]:
"generic_ef00_v1", "generic_ef00_v1",
"bosch_radiator_thermostat_ii", "bosch_radiator_thermostat_ii",
"im_speaker_ai_0001", "im_speaker_ai_0001",
"im_smarttag2_ble_uwb",
"abl_light_b_001", "abl_light_b_001",
"tplink_p110", "tplink_p110",
"ikea_kadrilj", "ikea_kadrilj",

View File

@ -32,7 +32,7 @@
"timestamp": "2025-02-09T14:35:56.800Z" "timestamp": "2025-02-09T14:35:56.800Z"
}, },
"supportedAcModes": { "supportedAcModes": {
"value": ["auto", "cool", "dry", "wind", "heat", "dryClean"], "value": ["auto", "cool", "dry", "fan", "heat", "dryClean"],
"timestamp": "2025-02-09T15:42:13.444Z" "timestamp": "2025-02-09T15:42:13.444Z"
}, },
"airConditionerMode": { "airConditionerMode": {

View File

@ -0,0 +1,129 @@
{
"components": {
"main": {
"tag.e2eEncryption": {
"encryption": {
"value": null
}
},
"audioVolume": {
"volume": {
"value": null
}
},
"geofence": {
"enableState": {
"value": null
},
"geofence": {
"value": null
},
"name": {
"value": null
}
},
"tag.updatedInfo": {
"connection": {
"value": "connected",
"timestamp": "2024-02-27T17:44:57.638Z"
}
},
"tag.factoryReset": {},
"battery": {
"quantity": {
"value": null
},
"battery": {
"value": null
},
"type": {
"value": null
}
},
"firmwareUpdate": {
"lastUpdateStatusReason": {
"value": null
},
"availableVersion": {
"value": null
},
"lastUpdateStatus": {
"value": null
},
"supportedCommands": {
"value": null
},
"state": {
"value": null
},
"updateAvailable": {
"value": false,
"timestamp": "2024-06-25T05:56:22.227Z"
},
"currentVersion": {
"value": null
},
"lastUpdateTime": {
"value": null
}
},
"tag.searchingStatus": {
"searchingStatus": {
"value": null
}
},
"tag.tagStatus": {
"connectedUserId": {
"value": null
},
"tagStatus": {
"value": null
},
"connectedDeviceId": {
"value": null
}
},
"alarm": {
"alarm": {
"value": null
}
},
"tag.tagButton": {
"tagButton": {
"value": null
}
},
"tag.uwbActivation": {
"uwbActivation": {
"value": null
}
},
"geolocation": {
"method": {
"value": null
},
"heading": {
"value": null
},
"latitude": {
"value": null
},
"accuracy": {
"value": null
},
"altitudeAccuracy": {
"value": null
},
"speed": {
"value": null
},
"longitude": {
"value": null
},
"lastUpdateTime": {
"value": null
}
}
}
}
}

View File

@ -0,0 +1,184 @@
{
"items": [
{
"deviceId": "83d660e4-b0c8-4881-a674-d9f1730366c1",
"name": "Tag(UWB)",
"label": "SmartTag+ black",
"manufacturerName": "Samsung Electronics",
"presentationId": "IM-SmartTag-BLE-UWB",
"deviceManufacturerCode": "Samsung Electronics",
"locationId": "redacted_locid",
"ownerId": "redacted",
"roomId": "redacted_roomid",
"components": [
{
"id": "main",
"label": "main",
"capabilities": [
{
"id": "alarm",
"version": 1
},
{
"id": "tag.tagButton",
"version": 1
},
{
"id": "audioVolume",
"version": 1
},
{
"id": "battery",
"version": 1
},
{
"id": "tag.factoryReset",
"version": 1
},
{
"id": "tag.e2eEncryption",
"version": 1
},
{
"id": "tag.tagStatus",
"version": 1
},
{
"id": "geolocation",
"version": 1
},
{
"id": "geofence",
"version": 1
},
{
"id": "tag.uwbActivation",
"version": 1
},
{
"id": "tag.updatedInfo",
"version": 1
},
{
"id": "tag.searchingStatus",
"version": 1
},
{
"id": "firmwareUpdate",
"version": 1
}
],
"categories": [
{
"name": "BluetoothTracker",
"categoryType": "manufacturer"
}
]
}
],
"createTime": "2023-05-25T09:42:59.720Z",
"profile": {
"id": "e443f3e8-a926-3deb-917c-e5c6de3af70f"
},
"bleD2D": {
"encryptionKey": "ZTbd_04NISrhQODE7_i8JdcG2ZWwqmUfY60taptK7J0=",
"cipher": "AES_128-CBC-PKCS7Padding",
"identifier": "415D4Y16F97F",
"configurationVersion": "2.0",
"configurationUrl": "https://apis.samsungiotcloud.com/v1/miniature/profile/b8e65e7e-6152-4704-b9f5-f16352034237",
"bleDeviceType": "BLE",
"metadata": {
"regionCode": 11,
"privacyIdPoolSize": 2000,
"privacyIdSeed": "AAAAAAAX8IQ=",
"privacyIdInitialVector": "ZfqZKLRGSeCwgNhdqHFRpw==",
"numAllowableConnections": 2,
"firmware": {
"version": "1.03.07",
"specVersion": "0.5.6",
"updateTime": 1685007914000,
"latestFirmware": {
"id": 581,
"version": "1.03.07",
"data": {
"checksum": "50E7",
"size": "586004",
"supportedVersion": "0.5.6"
}
}
},
"currentServerTime": 1739095473,
"searchingStatus": "stop",
"lastKnownConnection": {
"updated": 1713422813,
"connectedUser": {
"id": "sk3oyvsbkm",
"name": ""
},
"connectedDevice": {
"id": "4f3faa4c-976c-3bd8-b209-607f3a5a9814",
"name": ""
},
"d2dStatus": "bleScanned",
"nearby": true,
"onDemand": false
},
"e2eEncryption": {
"enabled": false
},
"timer": 1713422675,
"category": {
"id": 0
},
"remoteRing": {
"enabled": false
},
"petWalking": {
"enabled": false
},
"onboardedBy": {
"saGuid": "sk3oyvsbkm"
},
"shareable": {
"enabled": false
},
"agingCounter": {
"status": "VALID",
"updated": 1713422675
},
"vendor": {
"mnId": "0AFD",
"setupId": "432",
"modelName": "EI-T7300"
},
"priorityConnection": {
"lba": false,
"cameraShutter": false
},
"createTime": 1685007780,
"updateTime": 1713422675,
"fmmSearch": false,
"ooTime": {
"currentOoTime": 8,
"defaultOoTime": 8
},
"pidPoolSize": {
"desiredPidPoolSize": 2000,
"currentPidPoolSize": 2000
},
"activeMode": {
"mode": 0
},
"itemConfig": {
"searchingStatus": "stop"
}
}
},
"type": "BLE_D2D",
"restrictionTier": 0,
"allowed": [],
"executionContext": "CLOUD"
}
],
"_links": {}
}

View File

@ -146,7 +146,7 @@
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>, <HVACMode.DRY: 'dry'>,
<HVACMode.FAN_ONLY: 'fan_only'>, <HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.HEAT_COOL: 'heat_cool'>, <HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>, <HVACMode.HEAT: 'heat'>,
]), ]),
'max_temp': 35, 'max_temp': 35,
@ -206,7 +206,7 @@
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>, <HVACMode.DRY: 'dry'>,
<HVACMode.FAN_ONLY: 'fan_only'>, <HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.HEAT_COOL: 'heat_cool'>, <HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>, <HVACMode.HEAT: 'heat'>,
]), ]),
'max_temp': 35, 'max_temp': 35,
@ -246,7 +246,7 @@
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>, <HVACMode.DRY: 'dry'>,
<HVACMode.FAN_ONLY: 'fan_only'>, <HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.HEAT_COOL: 'heat_cool'>, <HVACMode.AUTO: 'auto'>,
]), ]),
'max_temp': 35, 'max_temp': 35,
'min_temp': 7, 'min_temp': 7,
@ -308,7 +308,7 @@
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>, <HVACMode.DRY: 'dry'>,
<HVACMode.FAN_ONLY: 'fan_only'>, <HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.HEAT_COOL: 'heat_cool'>, <HVACMode.AUTO: 'auto'>,
]), ]),
'max_temp': 35, 'max_temp': 35,
'min_temp': 7, 'min_temp': 7,
@ -349,7 +349,7 @@
]), ]),
'hvac_modes': list([ 'hvac_modes': list([
<HVACMode.OFF: 'off'>, <HVACMode.OFF: 'off'>,
<HVACMode.HEAT_COOL: 'heat_cool'>, <HVACMode.AUTO: 'auto'>,
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>, <HVACMode.DRY: 'dry'>,
<HVACMode.FAN_ONLY: 'fan_only'>, <HVACMode.FAN_ONLY: 'fan_only'>,
@ -414,7 +414,7 @@
'friendly_name': 'Aire Dormitorio Principal', 'friendly_name': 'Aire Dormitorio Principal',
'hvac_modes': list([ 'hvac_modes': list([
<HVACMode.OFF: 'off'>, <HVACMode.OFF: 'off'>,
<HVACMode.HEAT_COOL: 'heat_cool'>, <HVACMode.AUTO: 'auto'>,
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>, <HVACMode.DRY: 'dry'>,
<HVACMode.FAN_ONLY: 'fan_only'>, <HVACMode.FAN_ONLY: 'fan_only'>,
@ -462,7 +462,7 @@
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>, <HVACMode.DRY: 'dry'>,
<HVACMode.FAN_ONLY: 'fan_only'>, <HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.HEAT_COOL: 'heat_cool'>, <HVACMode.AUTO: 'auto'>,
]), ]),
'max_temp': 35, 'max_temp': 35,
'min_temp': 7, 'min_temp': 7,
@ -513,7 +513,7 @@
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>, <HVACMode.DRY: 'dry'>,
<HVACMode.FAN_ONLY: 'fan_only'>, <HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.HEAT_COOL: 'heat_cool'>, <HVACMode.AUTO: 'auto'>,
]), ]),
'max_temp': 35, 'max_temp': 35,
'min_temp': 7, 'min_temp': 7,
@ -541,7 +541,7 @@
'hvac_modes': list([ 'hvac_modes': list([
<HVACMode.OFF: 'off'>, <HVACMode.OFF: 'off'>,
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,
<HVACMode.HEAT_COOL: 'heat_cool'>, <HVACMode.AUTO: 'auto'>,
]), ]),
'max_temp': 35.0, 'max_temp': 35.0,
'min_temp': 7.0, 'min_temp': 7.0,
@ -589,7 +589,7 @@
'hvac_modes': list([ 'hvac_modes': list([
<HVACMode.OFF: 'off'>, <HVACMode.OFF: 'off'>,
<HVACMode.COOL: 'cool'>, <HVACMode.COOL: 'cool'>,
<HVACMode.HEAT_COOL: 'heat_cool'>, <HVACMode.AUTO: 'auto'>,
]), ]),
'max_temp': 35.0, 'max_temp': 35.0,
'min_temp': 7.0, 'min_temp': 7.0,

View File

@ -1487,6 +1487,39 @@
'via_device_id': None, 'via_device_id': None,
}) })
# --- # ---
# name: test_devices[im_smarttag2_ble_uwb]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': 'https://account.smartthings.com',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'smartthings',
'83d660e4-b0c8-4881-a674-d9f1730366c1',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': None,
'model': None,
'model_id': None,
'name': 'SmartTag+ black',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_devices[im_speaker_ai_0001] # name: test_devices[im_speaker_ai_0001]
DeviceRegistryEntrySnapshot({ DeviceRegistryEntrySnapshot({
'area_id': None, 'area_id': None,

View File

@ -119,7 +119,7 @@ async def test_ac_set_hvac_mode_off(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("hvac_mode", "argument"), ("hvac_mode", "argument"),
[ [
(HVACMode.HEAT_COOL, "auto"), (HVACMode.AUTO, "auto"),
(HVACMode.COOL, "cool"), (HVACMode.COOL, "cool"),
(HVACMode.DRY, "dry"), (HVACMode.DRY, "dry"),
(HVACMode.HEAT, "heat"), (HVACMode.HEAT, "heat"),
@ -174,7 +174,7 @@ async def test_ac_set_hvac_mode_turns_on(
SERVICE_SET_HVAC_MODE, SERVICE_SET_HVAC_MODE,
{ {
ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_ENTITY_ID: "climate.ac_office_granit",
ATTR_HVAC_MODE: HVACMode.HEAT_COOL, ATTR_HVAC_MODE: HVACMode.AUTO,
}, },
blocking=True, blocking=True,
) )
@ -196,17 +196,19 @@ async def test_ac_set_hvac_mode_turns_on(
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_ac_set_hvac_mode_wind( @pytest.mark.parametrize("mode", ["fan", "wind"])
async def test_ac_set_hvac_mode_fan(
hass: HomeAssistant, hass: HomeAssistant,
devices: AsyncMock, devices: AsyncMock,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
mode: str,
) -> None: ) -> None:
"""Test setting AC HVAC mode to wind if the device supports it.""" """Test setting AC HVAC mode to wind if the device supports it."""
set_attribute_value( set_attribute_value(
devices, devices,
Capability.AIR_CONDITIONER_MODE, Capability.AIR_CONDITIONER_MODE,
Attribute.SUPPORTED_AC_MODES, Attribute.SUPPORTED_AC_MODES,
["auto", "cool", "dry", "heat", "wind"], ["auto", "cool", "dry", "heat", mode],
) )
set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on")
@ -223,7 +225,7 @@ async def test_ac_set_hvac_mode_wind(
Capability.AIR_CONDITIONER_MODE, Capability.AIR_CONDITIONER_MODE,
Command.SET_AIR_CONDITIONER_MODE, Command.SET_AIR_CONDITIONER_MODE,
MAIN, MAIN,
argument="wind", argument=mode,
) )
@ -266,7 +268,7 @@ async def test_ac_set_temperature_and_hvac_mode_while_off(
{ {
ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_ENTITY_ID: "climate.ac_office_granit",
ATTR_TEMPERATURE: 23, ATTR_TEMPERATURE: 23,
ATTR_HVAC_MODE: HVACMode.HEAT_COOL, ATTR_HVAC_MODE: HVACMode.AUTO,
}, },
blocking=True, blocking=True,
) )
@ -316,7 +318,7 @@ async def test_ac_set_temperature_and_hvac_mode(
{ {
ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_ENTITY_ID: "climate.ac_office_granit",
ATTR_TEMPERATURE: 23, ATTR_TEMPERATURE: 23,
ATTR_HVAC_MODE: HVACMode.HEAT_COOL, ATTR_HVAC_MODE: HVACMode.AUTO,
}, },
blocking=True, blocking=True,
) )
@ -623,7 +625,7 @@ async def test_thermostat_set_hvac_mode(
await hass.services.async_call( await hass.services.async_call(
CLIMATE_DOMAIN, CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE, SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.asd", ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, {ATTR_ENTITY_ID: "climate.asd", ATTR_HVAC_MODE: HVACMode.AUTO},
blocking=True, blocking=True,
) )
devices.execute_device_command.assert_called_once_with( devices.execute_device_command.assert_called_once_with(

View File

@ -59,6 +59,37 @@ async def test_devices(
assert device == snapshot assert device == snapshot
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_device_not_resetting_area(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test device not resetting area."""
await setup_integration(hass, mock_config_entry)
device_id = devices.get_devices.return_value[0].device_id
device = device_registry.async_get_device({(DOMAIN, device_id)})
assert device.area_id == "theater"
device_registry.async_update_device(device_id=device.id, area_id=None)
await hass.async_block_till_done()
device = device_registry.async_get_device({(DOMAIN, device_id)})
assert device.area_id is None
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device({(DOMAIN, device_id)})
assert device.area_id is None
@pytest.mark.parametrize("device_fixture", ["button"]) @pytest.mark.parametrize("device_fixture", ["button"])
async def test_button_event( async def test_button_event(
hass: HomeAssistant, hass: HomeAssistant,

View File

@ -21,6 +21,7 @@ from tests.common import (
MOCK_DEVICE_NAME = "slzb-06" MOCK_DEVICE_NAME = "slzb-06"
MOCK_HOST = "192.168.1.161" MOCK_HOST = "192.168.1.161"
MOCK_HOSTNAME = "slzb-06p7.lan"
MOCK_USERNAME = "test-user" MOCK_USERNAME = "test-user"
MOCK_PASSWORD = "test-pass" MOCK_PASSWORD = "test-pass"

View File

@ -15,7 +15,13 @@ from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .conftest import MOCK_DEVICE_NAME, MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME from .conftest import (
MOCK_DEVICE_NAME,
MOCK_HOST,
MOCK_HOSTNAME,
MOCK_PASSWORD,
MOCK_USERNAME,
)
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -53,14 +59,14 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
CONF_HOST: "slzb-06p7.local", CONF_HOST: MOCK_HOSTNAME,
}, },
) )
assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "SLZB-06p7" assert result2["title"] == "SLZB-06p7"
assert result2["data"] == { assert result2["data"] == {
CONF_HOST: MOCK_HOST, CONF_HOST: MOCK_HOSTNAME,
} }
assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff"
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -82,7 +88,7 @@ async def test_user_flow_auth(
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
CONF_HOST: "slzb-06p7.local", CONF_HOST: MOCK_HOSTNAME,
}, },
) )
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
@ -100,7 +106,7 @@ async def test_user_flow_auth(
assert result3["data"] == { assert result3["data"] == {
CONF_USERNAME: MOCK_USERNAME, CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD, CONF_PASSWORD: MOCK_PASSWORD,
CONF_HOST: MOCK_HOST, CONF_HOST: MOCK_HOSTNAME,
} }
assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff"
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1

View File

@ -4978,7 +4978,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'unknown', 'state': 'disconnected',
}) })
# --- # ---
# name: test_sensors[sensor.wall_connector_vehicle-statealt] # name: test_sensors[sensor.wall_connector_vehicle-statealt]
@ -4991,7 +4991,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'unknown', 'state': 'disconnected',
}) })
# --- # ---
# name: test_sensors[sensor.wall_connector_vehicle_2-entry] # name: test_sensors[sensor.wall_connector_vehicle_2-entry]
@ -5038,7 +5038,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'unknown', 'state': 'disconnected',
}) })
# --- # ---
# name: test_sensors[sensor.wall_connector_vehicle_2-statealt] # name: test_sensors[sensor.wall_connector_vehicle_2-statealt]
@ -5051,7 +5051,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'unknown', 'state': 'disconnected',
}) })
# --- # ---
# name: test_sensors_streaming[sensor.test_battery_level-state] # name: test_sensors_streaming[sensor.test_battery_level-state]

View File

@ -258,3 +258,67 @@ async def test_invalid_zha_event_type(
# `zha_send_event` accepts only zigpy responses, lists, and dicts # `zha_send_event` accepts only zigpy responses, lists, and dicts
with pytest.raises(TypeError): with pytest.raises(TypeError):
cluster_handler.zha_send_event(COMMAND_SINGLE, 123) cluster_handler.zha_send_event(COMMAND_SINGLE, 123)
async def test_client_unique_id_suffix_stripped(
hass: HomeAssistant, setup_zha, zigpy_device_mock
) -> None:
"""Test that the `_CLIENT_` unique ID suffix is stripped."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "event",
"event_type": "zha_event",
"event_data": {
"unique_id": "38:5b:44:ff:fe:a7:cc:69:1:0x0006", # no `_CLIENT` suffix
"endpoint_id": 1,
"cluster_id": 6,
"command": "on",
"args": [],
"params": {},
},
},
"action": {"service": "zha.test"},
}
},
)
service_calls = async_mock_service(hass, DOMAIN, "test")
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
security.IasZone.cluster_id,
security.IasWd.cluster_id,
],
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
)
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zha_device.device)
zha_device.emit_zha_event(
{
"unique_id": "38:5b:44:ff:fe:a7:cc:69:1:0x0006_CLIENT",
"endpoint_id": 1,
"cluster_id": 6,
"command": "on",
"args": [],
"params": {},
}
)
await hass.async_block_till_done(wait_background_tasks=True)
assert len(service_calls) == 1

View File

@ -1,6 +1,7 @@
"""Provide common Z-Wave JS fixtures.""" """Provide common Z-Wave JS fixtures."""
import asyncio import asyncio
from collections.abc import Generator
import copy import copy
import io import io
from typing import Any, cast from typing import Any, cast
@ -15,6 +16,7 @@ from zwave_js_server.version import VersionInfo
from homeassistant.components.zwave_js import PLATFORMS from homeassistant.components.zwave_js import PLATFORMS
from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.components.zwave_js.const import DOMAIN
from homeassistant.components.zwave_js.helpers import SERVER_VERSION_TIMEOUT
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.util.json import JsonArrayType from homeassistant.util.json import JsonArrayType
@ -587,6 +589,44 @@ def mock_client_fixture(
yield client yield client
@pytest.fixture(name="server_version_side_effect")
def server_version_side_effect_fixture() -> Any | None:
"""Return the server version side effect."""
return None
@pytest.fixture(name="get_server_version", autouse=True)
def mock_get_server_version(
server_version_side_effect: Any | None, server_version_timeout: int
) -> Generator[AsyncMock]:
"""Mock server version."""
version_info = VersionInfo(
driver_version="mock-driver-version",
server_version="mock-server-version",
home_id=1234,
min_schema_version=0,
max_schema_version=1,
)
with (
patch(
"homeassistant.components.zwave_js.helpers.get_server_version",
side_effect=server_version_side_effect,
return_value=version_info,
) as mock_version,
patch(
"homeassistant.components.zwave_js.helpers.SERVER_VERSION_TIMEOUT",
new=server_version_timeout,
),
):
yield mock_version
@pytest.fixture(name="server_version_timeout")
def mock_server_version_timeout() -> int:
"""Patch the timeout for getting server version."""
return SERVER_VERSION_TIMEOUT
@pytest.fixture(name="multisensor_6") @pytest.fixture(name="multisensor_6")
def multisensor_6_fixture(client, multisensor_6_state) -> Node: def multisensor_6_fixture(client, multisensor_6_state) -> Node:
"""Mock a multisensor 6 node.""" """Mock a multisensor 6 node."""
@ -843,7 +883,11 @@ async def integration_fixture(
platforms: list[Platform], platforms: list[Platform],
) -> MockConfigEntry: ) -> MockConfigEntry:
"""Set up the zwave_js integration.""" """Set up the zwave_js integration."""
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry = MockConfigEntry(
domain="zwave_js",
data={"url": "ws://test.org"},
unique_id=str(client.driver.controller.home_id),
)
entry.add_to_hass(hass) entry.add_to_hass(hass)
with patch("homeassistant.components.zwave_js.PLATFORMS", platforms): with patch("homeassistant.components.zwave_js.PLATFORMS", platforms):
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)

View File

@ -7,6 +7,7 @@ import json
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch
from aiohttp import ClientError
import pytest import pytest
from zwave_js_server.const import ( from zwave_js_server.const import (
ExclusionStrategy, ExclusionStrategy,
@ -5080,14 +5081,17 @@ async def test_subscribe_node_statistics(
async def test_hard_reset_controller( async def test_hard_reset_controller(
hass: HomeAssistant, hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
device_registry: dr.DeviceRegistry, device_registry: dr.DeviceRegistry,
client: MagicMock, client: MagicMock,
get_server_version: AsyncMock,
integration: MockConfigEntry, integration: MockConfigEntry,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
) -> None: ) -> None:
"""Test that the hard_reset_controller WS API call works.""" """Test that the hard_reset_controller WS API call works."""
entry = integration entry = integration
ws_client = await hass_ws_client(hass) ws_client = await hass_ws_client(hass)
assert entry.unique_id == "3245146787"
async def async_send_command_driver_ready( async def async_send_command_driver_ready(
message: dict[str, Any], message: dict[str, Any],
@ -5122,6 +5126,40 @@ async def test_hard_reset_controller(
assert client.async_send_command.call_args_list[0] == call( assert client.async_send_command.call_args_list[0] == call(
{"command": "driver.hard_reset"}, 25 {"command": "driver.hard_reset"}, 25
) )
assert entry.unique_id == "1234"
client.async_send_command.reset_mock()
# Test client connect error when getting the server version.
get_server_version.side_effect = ClientError("Boom!")
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/hard_reset_controller",
ENTRY_ID: entry.entry_id,
}
)
msg = await ws_client.receive_json()
device = device_registry.async_get_device(
identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])}
)
assert device is not None
assert msg["result"] == device.id
assert msg["success"]
assert client.async_send_command.call_count == 3
# The first call is the relevant hard reset command.
# 25 is the require_schema parameter.
assert client.async_send_command.call_args_list[0] == call(
{"command": "driver.hard_reset"}, 25
)
assert (
"Failed to get server version, cannot update config entry"
"unique id with new home id, after controller reset"
) in caplog.text
client.async_send_command.reset_mock() client.async_send_command.reset_mock()
@ -5162,6 +5200,8 @@ async def test_hard_reset_controller(
{"command": "driver.hard_reset"}, 25 {"command": "driver.hard_reset"}, 25
) )
client.async_send_command.reset_mock()
# Test FailedZWaveCommand is caught # Test FailedZWaveCommand is caught
with patch( with patch(
"zwave_js_server.model.driver.Driver.async_hard_reset", "zwave_js_server.model.driver.Driver.async_hard_reset",

View File

@ -17,8 +17,9 @@ from zwave_js_server.exceptions import FailedCommand
from zwave_js_server.version import VersionInfo from zwave_js_server.version import VersionInfo
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries, data_entry_flow
from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE from homeassistant.components.zwave_js.config_flow import TITLE
from homeassistant.components.zwave_js.const import ADDON_SLUG, CONF_USB_PATH, DOMAIN from homeassistant.components.zwave_js.const import ADDON_SLUG, CONF_USB_PATH, DOMAIN
from homeassistant.components.zwave_js.helpers import SERVER_VERSION_TIMEOUT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo
@ -89,44 +90,6 @@ def mock_supervisor_fixture() -> Generator[None]:
yield yield
@pytest.fixture(name="server_version_side_effect")
def server_version_side_effect_fixture() -> Any | None:
"""Return the server version side effect."""
return None
@pytest.fixture(name="get_server_version", autouse=True)
def mock_get_server_version(
server_version_side_effect: Any | None, server_version_timeout: int
) -> Generator[AsyncMock]:
"""Mock server version."""
version_info = VersionInfo(
driver_version="mock-driver-version",
server_version="mock-server-version",
home_id=1234,
min_schema_version=0,
max_schema_version=1,
)
with (
patch(
"homeassistant.components.zwave_js.config_flow.get_server_version",
side_effect=server_version_side_effect,
return_value=version_info,
) as mock_version,
patch(
"homeassistant.components.zwave_js.config_flow.SERVER_VERSION_TIMEOUT",
new=server_version_timeout,
),
):
yield mock_version
@pytest.fixture(name="server_version_timeout")
def mock_server_version_timeout() -> int:
"""Patch the timeout for getting server version."""
return SERVER_VERSION_TIMEOUT
@pytest.fixture(name="addon_setup_time", autouse=True) @pytest.fixture(name="addon_setup_time", autouse=True)
def mock_addon_setup_time() -> Generator[None]: def mock_addon_setup_time() -> Generator[None]:
"""Mock add-on setup sleep time.""" """Mock add-on setup sleep time."""

View File

@ -12,6 +12,7 @@ from homeassistant.components.zwave_js.helpers import get_device_id
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers import device_registry as dr, issue_registry as ir
from tests.common import MockConfigEntry
from tests.components.repairs import ( from tests.components.repairs import (
async_process_repairs_platforms, async_process_repairs_platforms,
process_repair_fix_flow, process_repair_fix_flow,
@ -268,3 +269,118 @@ async def test_abort_confirm(
assert data["type"] == "abort" assert data["type"] == "abort"
assert data["reason"] == "cannot_connect" assert data["reason"] == "cannot_connect"
assert data["description_placeholders"] == {"device_name": device.name} assert data["description_placeholders"] == {"device_name": device.name}
@pytest.mark.usefixtures("client")
async def test_migrate_unique_id(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the migrate unique id flow."""
old_unique_id = "123456789"
config_entry = MockConfigEntry(
domain=DOMAIN,
title="Z-Wave JS",
data={
"url": "ws://test.org",
},
unique_id=old_unique_id,
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await async_process_repairs_platforms(hass)
ws_client = await hass_ws_client(hass)
http_client = await hass_client()
# Assert the issue is present
await ws_client.send_json_auto_id({"type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) == 1
issue = msg["result"]["issues"][0]
issue_id = issue["issue_id"]
assert issue_id == f"migrate_unique_id.{config_entry.entry_id}"
data = await start_repair_fix_flow(http_client, DOMAIN, issue_id)
flow_id = data["flow_id"]
assert data["step_id"] == "confirm"
assert data["description_placeholders"] == {
"config_entry_title": "Z-Wave JS",
"controller_model": "ZW090",
"new_unique_id": "3245146787",
"old_unique_id": old_unique_id,
}
# Apply fix
data = await process_repair_fix_flow(http_client, flow_id)
assert data["type"] == "create_entry"
assert config_entry.unique_id == "3245146787"
await ws_client.send_json_auto_id({"type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) == 0
@pytest.mark.usefixtures("client")
async def test_migrate_unique_id_missing_config_entry(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the migrate unique id flow with missing config entry."""
old_unique_id = "123456789"
config_entry = MockConfigEntry(
domain=DOMAIN,
title="Z-Wave JS",
data={
"url": "ws://test.org",
},
unique_id=old_unique_id,
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await async_process_repairs_platforms(hass)
ws_client = await hass_ws_client(hass)
http_client = await hass_client()
# Assert the issue is present
await ws_client.send_json_auto_id({"type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) == 1
issue = msg["result"]["issues"][0]
issue_id = issue["issue_id"]
assert issue_id == f"migrate_unique_id.{config_entry.entry_id}"
await hass.config_entries.async_remove(config_entry.entry_id)
assert not hass.config_entries.async_get_entry(config_entry.entry_id)
data = await start_repair_fix_flow(http_client, DOMAIN, issue_id)
flow_id = data["flow_id"]
assert data["step_id"] == "confirm"
assert data["description_placeholders"] == {
"config_entry_title": "Z-Wave JS",
"controller_model": "ZW090",
"new_unique_id": "3245146787",
"old_unique_id": old_unique_id,
}
# Apply fix
data = await process_repair_fix_flow(http_client, flow_id)
assert data["type"] == "create_entry"
await ws_client.send_json_auto_id({"type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) == 0