Compare commits

...

65 Commits

Author SHA1 Message Date
Franck Nijhof 7e62ff35fd 2026.6.4 (#174308) 2026-06-19 22:57:25 +02:00
TheJulianJES 0a5c1ef8eb Bump dsmr-parser to 1.9.0 (#174307) 2026-06-19 18:59:16 +00:00
Michael a88093afd2 Bump aioimmich to 0.15.0 (#174305) 2026-06-19 18:55:13 +00:00
J. Nick Koston 9fd90283b3 Bump pyroute2 to 0.9.6 (#172521) 2026-06-19 18:55:11 +00:00
Franck Nijhof 3159242b68 Bump version to 2026.6.4 2026-06-19 18:26:51 +00:00
Simone Chemelli f5985b03e4 Remove event entities from virtual groups for Alexa Devices (#174303) 2026-06-19 18:25:53 +00:00
Franck Nijhof 040a3bcb10 Cast Xiaomi Gateway sub-device firmware version to string (#174294) 2026-06-19 18:25:52 +00:00
Bram Kragten 5aaf6704a9 Update frontend to 20260527.7 (#174285) 2026-06-19 18:25:50 +00:00
Franck Nijhof 2fcd00b301 Fix econet fan mode select returning int instead of str (#174274) 2026-06-19 18:25:48 +00:00
Franck Nijhof 0b439e6e4c Re-raise non-401 HTTP errors in Tank Utility setup (#174272) 2026-06-19 18:25:46 +00:00
Franck Nijhof d13a5b7eec Handle Weheat API errors during config flow entry creation (#174234) 2026-06-19 18:25:45 +00:00
Franck Nijhof de49716ec1 Skip fill sensor for Rituals diffusers without fill data (#174232) 2026-06-19 18:25:43 +00:00
Franck Nijhof 67c6921847 Include Sonos favorites in source list and gate SELECT_SOURCE dynamically (#174231) 2026-06-19 18:25:41 +00:00
Franck Nijhof 002b638013 Fix Elk-M1 reconfigure failing when entry has no unique_id (#174230) 2026-06-19 18:25:39 +00:00
Josef Zweck 4b60ed30c7 Update max prebrew numbers in lamarzocco (#174204) 2026-06-19 18:25:37 +00:00
Simone Chemelli 6f1deec507 Bump aiocomelit to 2.0.7 (#174183) 2026-06-19 18:25:35 +00:00
Franck Nijhof 227ba8032f Bump aiocomelit to 2.0.5 (#173800) 2026-06-19 18:25:34 +00:00
Bernát Gábor 7da3ecf033 Cast SwitchBot Cloud device sw_version to string (#174167) 2026-06-19 18:14:20 +00:00
Simone Chemelli 8b293a18d3 Bump aioamazondevices to 14.1.3 (#174158) 2026-06-19 18:14:18 +00:00
Simone Chemelli 3dc077f280 Fix stale routine entities removal for Alexa Devices (#174138) 2026-06-19 18:14:16 +00:00
Simone Chemelli d368a95323 Bump aioamazondevices to 14.1.2 (#174114)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-06-19 18:14:14 +00:00
Franck Nijhof 495f41a742 Filter out closed sites in Amber Electric config flow (#174084) 2026-06-19 18:12:01 +00:00
Franck Nijhof 9f7529706d Cast system version to string for simplisafe device model (#174081) 2026-06-19 18:11:59 +00:00
Franck Nijhof 8f6b1dff9c Bump opower to 0.18.5 (#174080) 2026-06-19 18:11:58 +00:00
Franck Nijhof f260a1bb7b Retry webdav setup on connection errors (#174077) 2026-06-19 18:11:56 +00:00
Franck Nijhof 157e137ea9 Cast numeric firmware to string for squeezebox hw_version (#174076) 2026-06-19 18:11:54 +00:00
Jan Bouwhuis b2e1a296d4 Fix MQTT discovery option unjustly added to entry data (#174073) 2026-06-19 18:11:52 +00:00
Franck Nijhof e78a2c9f01 Add missing subentry flow translations in scrape (#174006) 2026-06-19 18:11:50 +00:00
Franck Nijhof 9011225a42 Add missing flow form field translations in tractive (#174005) 2026-06-19 18:11:48 +00:00
Franck Nijhof 81ef9b99c2 Add missing flow form field translation in iskra (#174004) 2026-06-19 18:11:46 +00:00
Franck Nijhof fa0207698a Add missing flow form field translations in ecobee (#174002) 2026-06-19 18:11:44 +00:00
Franck Nijhof 275883a95a Add missing flow form field translation in airvisual (#174000) 2026-06-19 18:11:42 +00:00
Franck Nijhof ebd252a225 Fix flow form field translations in modem_callerid (#173999) 2026-06-19 18:11:40 +00:00
Franck Nijhof 2de6c0281d Fix flow form field translation key in sia (#173998) 2026-06-19 18:11:38 +00:00
Franck Nijhof f95671f0f4 Fix flow form field translations in local_calendar (#173997) 2026-06-19 18:11:36 +00:00
Franck Nijhof 5fcae9ecf7 Add missing flow form field translation in honeywell (#173996) 2026-06-19 18:11:35 +00:00
Franck Nijhof 0b86cfa496 Add missing flow form field translation in otp (#173994) 2026-06-19 18:11:33 +00:00
Franck Nijhof d45bdf37d5 Fix flow form field translations in hlk_sw16 (#173993) 2026-06-19 18:11:31 +00:00
Franck Nijhof a9205df4a3 Fix flow form field translation keys in here_travel_time (#173992) 2026-06-19 18:11:29 +00:00
John Pettitt c333744fd2 Add API_GEN_4 support to Subaru integration (#173956) 2026-06-19 18:11:27 +00:00
Assaf Inbal 2f64601990 Bump pyituran to 0.1.6 (#173833) 2026-06-19 18:11:25 +00:00
Franck Nijhof cbd35be271 Bump pyrainbird to 6.3.1 (#173786) 2026-06-19 18:07:34 +00:00
Franck Nijhof 92ac14f42a Bump aioamazondevices to 14.0.4 (#173761) 2026-06-19 18:07:32 +00:00
Franck Nijhof 45e568c73e Add missing flow form field translation in snooz (#173760) 2026-06-19 18:07:30 +00:00
Franck Nijhof a121b8d146 Add missing flow form field translation in motionblinds_ble (#173758) 2026-06-19 18:07:28 +00:00
Franck Nijhof a2bd7d5857 Add missing flow form field translation in blink (#173756) 2026-06-19 18:07:26 +00:00
Franck Nijhof a6e639377b Fix options flow form field translation key in plaato (#173755) 2026-06-19 18:07:24 +00:00
Franck Nijhof 2147a851c3 Fix flow form field translation key in meteoclimatic (#173754) 2026-06-19 18:07:22 +00:00
Franck Nijhof 9034afd29e Add missing flow form field translation in gogogate2 (#173753) 2026-06-19 18:07:20 +00:00
Franck Nijhof 5c5d259f63 Add missing flow form field translation in melnor (#173752) 2026-06-19 18:07:18 +00:00
Franck Nijhof cc16a9086f Fix flow form field translation key in lookin (#173751) 2026-06-19 18:07:16 +00:00
Franck Nijhof 5d1f8f770c Add missing flow form field translation in lacrosse_view (#173750) 2026-06-19 18:07:14 +00:00
Franck Nijhof cea6b9b0b7 Add missing flow form field translations in islamic_prayer_times (#173749) 2026-06-19 18:07:12 +00:00
G Johansson 77f7c26399 Bump lxml to 6.1.1 (#173748) 2026-06-19 18:07:10 +00:00
Franck Nijhof 8e0a5b258c Add missing flow form field translation in hue (#173747) 2026-06-19 18:07:08 +00:00
Franck Nijhof f8b942818c Add missing flow form field translation in flux_led (#173746) 2026-06-19 18:07:06 +00:00
Franck Nijhof 9660d12c77 Add missing flow form field translation in tuya (#173745) 2026-06-19 18:07:04 +00:00
Franck Nijhof 7f1533a6e1 Skip Miele fan set_percentage when already at the target step (#173725) 2026-06-19 18:07:02 +00:00
J. Nick Koston 336d9e9126 Bump aiodiscover to 3.3.2 (#173705) 2026-06-19 18:07:00 +00:00
J. Nick Koston 1dde2d918e Bump aiodiscover to 3.3.1 (#172882) 2026-06-19 18:06:58 +00:00
Åke Strandberg 34a6b0ca61 Add missing Miele dishwasher codes (#173662) 2026-06-19 17:50:28 +00:00
Raman Gupta e92286ecd6 Stop validating # of slots in zwave_js.set_credential action (#173644) 2026-06-19 17:50:26 +00:00
Franck Nijhof 82bb9748db Avoid leaking Immich API key in error logs (#173541) 2026-06-19 17:50:24 +00:00
Rob Bierbooms 68e5e58a1c Solve issue with double slash in url when writing data to InfluxDB (#173395) 2026-06-19 17:50:22 +00:00
johanzander f3e8403e9a Fix Growatt total_output_power 1000x too low with V1 API (#172474)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-19 17:50:20 +00:00
101 changed files with 922 additions and 330 deletions
@@ -37,6 +37,9 @@
"title": "Re-authenticate AirVisual"
},
"user": {
"data": {
"type": "Integration type"
},
"description": "Pick what type of AirVisual data you want to monitor.",
"title": "Configure AirVisual"
}
@@ -190,8 +190,11 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
await self._async_remove_device_stale(stale_devices)
self.previous_devices = current_devices
current_routines = {slugify(routine) for routine in self.api.routines}
if stale_routines := self.previous_routines - current_routines:
current_routines = {
f"{slugify(self.config_entry.unique_id)}-{slugify(routine)}"
for routine in self.api.routines
}
if stale_routines := (self.previous_routines - current_routines):
await self._async_remove_routine_stale(stale_routines)
self.previous_routines = current_routines
@@ -225,17 +228,19 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
"""Remove stale routine."""
entity_registry = er.async_get(self.hass)
for routine in stale_routines:
_LOGGER.debug(
"Detected change in routines: routine %s removed",
routine,
)
for routine_unique_id in stale_routines:
entity_id = entity_registry.async_get_entity_id(
Platform.BUTTON,
DOMAIN,
f"{slugify(self.config_entry.unique_id)}-{slugify(routine)}",
routine_unique_id,
)
if entity_id:
_LOGGER.debug(
"Detected change in routines: routine %s removed",
routine_unique_id.replace(
f"{slugify(self.config_entry.unique_id)}-", ""
),
)
entity_registry.async_remove(entity_id)
async def sync_history_state(self) -> None:
@@ -2,13 +2,20 @@
from typing import Final
from homeassistant.components.event import EventEntity, EventEntityDescription
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from homeassistant.components.event import (
DOMAIN as EVENT_DOMAIN,
EventEntity,
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
from .utils import async_remove_entity_from_virtual_group
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -31,6 +38,11 @@ async def async_setup_entry(
"""Set up Alexa Devices events based on a config entry."""
coordinator = entry.runtime_data
# Remove voice event from virtual groups
await async_remove_entity_from_virtual_group(
hass, coordinator, EVENT_DOMAIN, "voice_event"
)
known_devices: set[str] = set()
def _check_device() -> None:
@@ -42,6 +54,7 @@ async def async_setup_entry(
AlexaVoiceEvent(coordinator, serial_num, event_desc)
for event_desc in EVENTS
for serial_num in new_devices
if coordinator.data[serial_num].device_family != SPEAKER_GROUP_FAMILY
)
_check_device()
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.3"]
"requirements": ["aioamazondevices==14.1.3"]
}
@@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry, alexa_api_call
from .entity import AmazonEntity
from .utils import async_remove_dnd_from_virtual_group, async_update_unique_id
from .utils import async_remove_entity_from_virtual_group, async_update_unique_id
PARALLEL_UPDATES = 1
@@ -58,7 +58,9 @@ async def async_setup_entry(
new_key = "dnd"
# Remove old DND switch from virtual groups
await async_remove_dnd_from_virtual_group(hass, coordinator, old_key)
await async_remove_entity_from_virtual_group(
hass, coordinator, SWITCH_DOMAIN, old_key
)
# Replace unique id for DND switch
await async_update_unique_id(hass, coordinator, SWITCH_DOMAIN, old_key, new_key)
@@ -8,7 +8,6 @@ from aioamazondevices.const.schedules import (
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant
import homeassistant.helpers.entity_registry as er
@@ -38,23 +37,22 @@ async def async_update_unique_id(
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
async def async_remove_dnd_from_virtual_group(
async def async_remove_entity_from_virtual_group(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
platform: str,
key: str,
) -> None:
"""Remove entity DND from virtual group."""
"""Remove entity from virtual group."""
entity_registry = er.async_get(hass)
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{key}"
entity_id = entity_registry.async_get_entity_id(
DOMAIN, SWITCH_DOMAIN, unique_id
)
entity_id = entity_registry.async_get_entity_id(DOMAIN, platform, unique_id)
is_group = coordinator.data[serial_num].device_family == SPEAKER_GROUP_FAMILY
if entity_id and is_group:
entity_registry.async_remove(entity_id)
_LOGGER.debug("Removed DND switch from virtual group %s", entity_id)
_LOGGER.debug("Removed entity '%s' from virtual group", entity_id)
async def async_remove_unsupported_notification_sensors(
@@ -34,11 +34,13 @@ def generate_site_selector_name(site: Site) -> str:
def filter_sites(sites: list[Site]) -> list[Site]:
"""Deduplicates the list of sites."""
"""Filter out closed sites and deduplicate the list of sites."""
filtered: list[Site] = []
filtered_nmi: set[str] = set()
for site in sorted(sites, key=lambda site: site.status):
if site.status == SiteStatus.CLOSED:
continue
if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi:
filtered.append(site)
filtered_nmi.add(site.nmi)
@@ -26,6 +26,12 @@
"description": "The credentials for {username} need to be updated",
"title": "Re-authenticate Blink"
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==2.0.3"]
"requirements": ["aiocomelit==2.0.7"]
}
+2 -2
View File
@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.7",
"aiodiscover==3.2.4",
"cached-ipaddress==1.1.1"
"aiodiscover==3.3.2",
"cached-ipaddress==1.1.2"
]
}
+1 -1
View File
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["dsmr_parser"],
"requirements": ["dsmr-parser==1.7.0"]
"requirements": ["dsmr-parser==1.9.0"]
}
+3 -1
View File
@@ -30,7 +30,9 @@
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
"api_key": "[%key:common::config_flow::data::api_key%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Please enter the API key obtained from ecobee.com."
}
+2 -2
View File
@@ -39,12 +39,12 @@ class EconetFanModeSelect(EcoNetEntity[Thermostat], SelectEntity):
@property
def options(self) -> list[str]:
"""Return available select options."""
return [e.value for e in self._econet.fan_modes]
return [e.name for e in self._econet.fan_modes]
@property
def current_option(self) -> str:
"""Return current select option."""
return self._econet.fan_mode.value
return self._econet.fan_mode.name
def select_option(self, option: str) -> None:
"""Set the selected option."""
@@ -246,8 +246,8 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
)
if device is not None and device.mac_address:
await self.async_set_unique_id(dr.format_mac(device.mac_address))
# aborts if user tried to switch devices
self._abort_if_unique_id_mismatch()
if reconfigure_entry.unique_id is not None:
self._abort_if_unique_id_mismatch()
else:
# If we cannot confirm identity, keep existing
# behavior (don't block reconfigure)
@@ -255,6 +255,7 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_update_reload_and_abort(
reconfigure_entry,
unique_id=self.unique_id,
data_updates={
**reconfigure_entry.data,
CONF_HOST: info[CONF_HOST],
@@ -13,6 +13,11 @@
"discovery_confirm": {
"description": "Do you want to set up {model} {id} ({ipaddr})?"
},
"pick_device": {
"data": {
"device": "[%key:common::config_flow::data::device%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.6"]
"requirements": ["home-assistant-frontend==20260527.7"]
}
@@ -11,6 +11,7 @@
"step": {
"user": {
"data": {
"device": "[%key:common::config_flow::data::device%]",
"ip_address": "[%key:common::config_flow::data::ip%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
@@ -189,7 +189,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) from err
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
# V1 API returns current_power in kW, convert to W
total_info["invTodayPpv"] = total_info["current_power"] * 1000
else:
# Classic API: use plant_info as before.
# Copy the response to avoid mutating the dict returned by the library
@@ -15,7 +15,7 @@
},
"title": "[%key:component::here_travel_time::config::step::destination_menu::title%]"
},
"destination_entity_id": {
"destination_entity": {
"data": {
"destination_entity_id": "Destination using an entity"
},
@@ -34,7 +34,7 @@
},
"title": "[%key:component::here_travel_time::config::step::origin_menu::title%]"
},
"origin_entity_id": {
"origin_entity": {
"data": {
"origin_entity_id": "Origin using an entity"
},
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of your Hi-Link HLK-SW-16 device."
@@ -10,7 +10,8 @@
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "The Honeywell integration needs to re-authenticate your account",
"title": "[%key:common::config_flow::title::reauth%]"
+2 -1
View File
@@ -18,7 +18,8 @@
"step": {
"init": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
"host": "[%key:common::config_flow::data::host%]",
"id": "Hue bridge"
},
"data_description": {
"host": "The hostname or IP address of your Hue bridge."
@@ -81,6 +81,7 @@ class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]):
async def _async_setup(self) -> None:
"""Handle setup of the coordinator."""
try:
await self.api.async_setup()
user_info = await self.api.users.async_get_my_user()
except ImmichUnauthorizedError as err:
raise ConfigEntryAuthFailed(
@@ -119,7 +120,7 @@ class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]):
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": repr(err)},
translation_placeholders={"error": str(err)},
) from err
return ImmichData(
@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["aioimmich"],
"quality_scale": "platinum",
"requirements": ["aioimmich==0.14.1"]
"requirements": ["aioimmich==0.15.0"]
}
@@ -225,10 +225,9 @@ class ImmichMediaSource(MediaSource):
entry.title,
)
try:
album_info = await immich_api.albums.async_get_album_info(
identifier.collection_id
assets = await immich_api.search.async_get_all_by_album_ids(
[identifier.collection_id]
)
assets = album_info.assets
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
+1 -1
View File
@@ -53,7 +53,7 @@ async def _async_upload_file(service_call: ServiceCall) -> None:
if target_album := service_call.data.get(CONF_ALBUM_ID):
try:
await coordinator.api.albums.async_get_album_info(target_album, True)
await coordinator.api.albums.async_get_album_info(target_album)
except ImmichError as ex:
raise ServiceValidationError(
translation_domain=DOMAIN,
@@ -423,7 +423,7 @@ def get_influx_connection( # noqa: C901
if CONF_HOST in conf:
kwargs[CONF_HOST] = conf[CONF_HOST]
if (path := conf.get(CONF_PATH)) is not None:
if (path := conf.get(CONF_PATH)) is not None and path != "/":
kwargs[CONF_PATH] = path
if (port := conf.get(CONF_PORT)) is not None:
+2 -1
View File
@@ -31,7 +31,8 @@
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
"host": "[%key:common::config_flow::data::host%]",
"protocol": "Protocol"
},
"data_description": {
"host": "Hostname or IP address of your Iskra device."
@@ -5,6 +5,10 @@
},
"step": {
"user": {
"data": {
"location": "[%key:common::config_flow::data::location%]",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "Do you want to set up Islamic Prayer Times?",
"title": "Set up Islamic Prayer Times"
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["pyituran==0.1.5"]
"requirements": ["pyituran==0.1.6"]
}
@@ -10,6 +10,11 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"location": {
"data": {
"location": "[%key:common::config_flow::data::location%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -121,7 +121,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.SECONDS,
native_step=PRECISION_TENTHS,
native_min_value=0,
native_max_value=10,
native_max_value=30,
entity_category=EntityCategory.CONFIG,
set_value_fn=(
lambda machine, value: machine.set_pre_extraction_times(
@@ -163,7 +163,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.SECONDS,
native_step=PRECISION_TENTHS,
native_min_value=0,
native_max_value=10,
native_max_value=30,
entity_category=EntityCategory.CONFIG,
set_value_fn=(
lambda machine, value: machine.set_pre_extraction_times(
@@ -207,7 +207,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.SECONDS,
native_step=PRECISION_TENTHS,
native_min_value=0,
native_max_value=10,
native_max_value=30,
entity_category=EntityCategory.CONFIG,
set_value_fn=(
lambda machine, value: machine.set_pre_extraction_times(
@@ -7,7 +7,10 @@
"invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details."
},
"step": {
"import": {
"import_ics_file": {
"data": {
"ics_file": "ICS file"
},
"description": "You can import events in iCal format (.ics file)."
},
"user": {
+1 -1
View File
@@ -23,7 +23,7 @@
},
"user": {
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]"
"host": "[%key:common::config_flow::data::host%]"
}
}
}
@@ -8,6 +8,11 @@
"bluetooth_confirm": {
"description": "Do you want to add the Melnor Bluetooth valve `{name}` to Home Assistant?",
"title": "Discovered Melnor Bluetooth valve"
},
"pick_device": {
"data": {
"address": "[%key:common::config_flow::data::device%]"
}
}
}
},
@@ -10,10 +10,10 @@
"step": {
"user": {
"data": {
"code": "Station code"
"station_code": "Station code"
},
"data_description": {
"code": "Looks like ESCAT4300000043206B"
"station_code": "Looks like ESCAT4300000043206B"
}
}
}
+4 -3
View File
@@ -508,19 +508,20 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True):
solar_save = 9, 34
gentle = 10, 35, 210
extra_quiet = 11, 36, 207
hygiene = 12, 37
quick_power_wash = 13, 38
hygiene = 12, 37, 206
quick_power_wash = 13, 38, 216
pasta_paela = 14
tall_items = 17, 42
glasses_warm = 19
quick_intense = 21
normal = 23, 30
normal = 23, 30, 217
pre_wash = 24
pot_rests_and_filters = 25
power_wash = 44, 204
comfort_wash = 203
comfort_wash_plus = 209
rinse_salt = 215
rinse_and_hold = 219
class TumbleDryerProgramId(MieleEnum, missing_to_none=True):
+2
View File
@@ -135,6 +135,8 @@ class MieleFan(MieleEntity, FanEntity):
_LOGGER.debug("Calc ventilation_step: %s", ventilation_step)
if ventilation_step == 0:
await self.async_turn_off()
elif ventilation_step == self.device.state_ventilation_step:
return
else:
try:
await self.api.send_action(
@@ -791,6 +791,7 @@
"rice_pudding_rapid_steam_cooking": "Rice pudding (rapid steam cooking)",
"rice_pudding_steam_cooking": "Rice pudding (steam cooking)",
"rinse": "Rinse",
"rinse_and_hold": "Rinse and hold",
"rinse_out_lint": "Rinse out lint",
"rinse_salt": "Rinse salt",
"risotto": "Risotto",
@@ -14,8 +14,7 @@
},
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"port": "[%key:common::config_flow::data::port%]"
"device": "[%key:common::config_flow::data::device%]"
},
"description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call."
}
@@ -10,6 +10,9 @@
},
"step": {
"confirm": {
"data": {
"blind_type": "Blind type"
},
"description": "What kind of blind is {display_name}?"
},
"user": {
@@ -4179,7 +4179,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PROTOCOL: DEFAULT_PROTOCOL,
CONF_USERNAME: addon_discovery_config.get(CONF_USERNAME),
CONF_PASSWORD: addon_discovery_config.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
}
except AddonError:
# We do not have discovery information yet
@@ -4420,7 +4419,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PROTOCOL: DEFAULT_PROTOCOL,
CONF_USERNAME: data.get(CONF_USERNAME),
CONF_PASSWORD: data.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
},
)
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "platinum",
"requirements": ["opower==0.18.4"]
"requirements": ["opower==0.18.5"]
}
+2 -1
View File
@@ -11,7 +11,8 @@
"step": {
"confirm": {
"data": {
"code": "Verification code (OTP)"
"code": "Verification code (OTP)",
"qr_code": "QR code"
},
"data_description": {
"code": "The six-digit code currently displayed in your authentication app."
+1 -1
View File
@@ -41,7 +41,7 @@
"step": {
"user": {
"data": {
"update_interval": "Update interval (minutes)"
"scan_interval": "Update interval (minutes)"
},
"description": "Set the update interval (minutes)",
"title": "Options for Plaato"
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyrainbird"],
"requirements": ["pyrainbird==6.3.0"]
"requirements": ["pyrainbird==6.3.1"]
}
@@ -40,6 +40,7 @@ ENTITY_DESCRIPTIONS = (
key="fill",
translation_key="fill",
value_fn=lambda diffuser: diffuser.fill,
has_fn=lambda diffuser: "fillc" in diffuser.hub_data.get("sensors", {}),
),
RitualsSensorEntityDescription(
key="perfume",
@@ -6,5 +6,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/scrape",
"iot_class": "cloud_polling",
"requirements": ["beautifulsoup4==4.13.3", "lxml==6.0.1"]
"requirements": ["beautifulsoup4==4.13.3", "lxml==6.1.1"]
}
@@ -58,6 +58,38 @@
"user": "Add sensor"
},
"step": {
"reconfigure": {
"data": {
"index": "[%key:component::scrape::config_subentries::entity::step::user::data::index%]",
"select": "[%key:component::scrape::config_subentries::entity::step::user::data::select%]"
},
"data_description": {
"index": "[%key:component::scrape::config_subentries::entity::step::user::data_description::index%]",
"select": "[%key:component::scrape::config_subentries::entity::step::user::data_description::select%]"
},
"sections": {
"advanced": {
"data": {
"attribute": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::attribute%]",
"availability": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::availability%]",
"device_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::device_class%]",
"state_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::value_template%]"
},
"data_description": {
"attribute": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::attribute%]",
"availability": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::availability%]",
"device_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::device_class%]",
"state_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::value_template%]"
},
"description": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::description%]",
"name": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::name%]"
}
}
},
"user": {
"data": {
"index": "Index",
+1 -1
View File
@@ -13,7 +13,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"additional_account": {
"add_account": {
"data": {
"account": "[%key:component::sia::config::step::user::data::account%]",
"additional_account": "[%key:component::sia::config::step::user::data::additional_account%]",
@@ -247,7 +247,7 @@ def _async_register_base_station(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, str(system.system_id))},
manufacturer="SimpliSafe",
model=system.version,
model=str(system.version),
name=system.address,
)
+2 -1
View File
@@ -18,7 +18,8 @@
},
"user": {
"data": {
"address": "[%key:common::config_flow::data::device%]"
"address": "[%key:common::config_flow::data::device%]",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "[%key:component::bluetooth::config::step::user::description%]"
}
+26 -7
View File
@@ -64,6 +64,7 @@ from .const import (
MODELS_TV_ONLY,
PLAYABLE_MEDIA_TYPES,
SONOS_CREATE_MEDIA_PLAYER,
SONOS_FAVORITES_UPDATED,
SONOS_MEDIA_UPDATED,
SONOS_STATE_PLAYING,
SONOS_STATE_TRANSITIONING,
@@ -131,7 +132,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.SEARCH_MEDIA
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_MUTE
@@ -145,6 +145,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
super().__init__(speaker, config_entry)
self._attr_unique_id = self.soco.uid
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag media player features that are supported."""
features = self._attr_supported_features
if self.source_list:
features |= MediaPlayerEntityFeature.SELECT_SOURCE
return features
async def async_added_to_hass(self) -> None:
"""Handle common setup when added to hass."""
await super().async_added_to_hass()
@@ -155,6 +163,13 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
self.async_write_media_state,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SONOS_FAVORITES_UPDATED}-{self.speaker.household_id}",
self.async_write_ha_state,
)
)
@callback
def async_write_media_state(self, uid: str) -> None:
@@ -394,14 +409,18 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
@property
def source_list(self) -> list[str]:
"""List of available input sources."""
sources: list[str] = []
model = self.coordinator.model_name.split()[-1].upper()
if model in MODELS_LINEIN_ONLY:
return [SOURCE_LINEIN]
if model in MODELS_TV_ONLY:
return [SOURCE_TV]
if model in MODELS_LINEIN_AND_TV:
return [SOURCE_LINEIN, SOURCE_TV]
return []
sources = [SOURCE_LINEIN]
elif model in MODELS_TV_ONLY:
sources = [SOURCE_TV]
elif model in MODELS_LINEIN_AND_TV:
sources = [SOURCE_LINEIN, SOURCE_TV]
sources.extend(
fav.title for fav in self.speaker.favorites if fav.title not in sources
)
return sources
@soco_error(UPNP_ERRORS_TO_IGNORE)
def media_play(self) -> None:
@@ -160,7 +160,7 @@ async def async_setup_entry(
model=model,
manufacturer=manufacturer,
model_id=model_id,
hw_version=player.firmware,
hw_version=str(player.firmware) if player.firmware is not None else None,
sw_version=sw_version,
via_device=(DOMAIN, coordinator.server_uuid),
)
+1
View File
@@ -29,6 +29,7 @@ VEHICLE_STATUS = "vehicle_status"
API_GEN_1 = "g1"
API_GEN_2 = "g2"
API_GEN_3 = "g3"
API_GEN_4 = "g4"
MANUFACTURER = "Subaru"
PLATFORMS = [
+3 -2
View File
@@ -24,6 +24,7 @@ from . import get_device_info
from .const import (
API_GEN_2,
API_GEN_3,
API_GEN_4,
VEHICLE_API_GEN,
VEHICLE_HAS_EV,
VEHICLE_STATUS,
@@ -153,10 +154,10 @@ def create_vehicle_sensors(
sensor_descriptions_to_add = []
sensor_descriptions_to_add.extend(SAFETY_SENSORS)
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3]:
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3, API_GEN_4]:
sensor_descriptions_to_add.extend(API_GEN_2_SENSORS)
if vehicle_info[VEHICLE_API_GEN] == API_GEN_3:
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_3, API_GEN_4]:
sensor_descriptions_to_add.extend(API_GEN_3_SENSORS)
if vehicle_info[VEHICLE_HAS_EV]:
@@ -36,8 +36,11 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]):
self._api = api
self._attr_unique_id = device.device_id
_sw_version = None
if self.coordinator.data is not None:
_sw_version = self.coordinator.data.get("version")
if (
self.coordinator.data is not None
and (_version := self.coordinator.data.get("version")) is not None
):
_sw_version = str(_version)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.device_id)},
name=device.device_name,
@@ -61,6 +61,7 @@ def setup_platform(
if http_error.response.status_code == requests.codes.unauthorized:
_LOGGER.error("Invalid credentials")
return
raise
all_sensors = []
for device in devices:
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/thread",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.10.0", "pyroute2==0.7.5"],
"requirements": ["python-otbr-api==2.10.0", "pyroute2==0.9.6"],
"single_config_entry": true,
"zeroconf": ["_meshcop._udp.local."]
}
@@ -12,6 +12,12 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
@@ -15,6 +15,9 @@
"description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case-sensitive, please be sure to enter it exactly as shown in the app."
},
"scan": {
"data": {
"QR": "QR code"
},
"description": "Use the Smart Life app or Tuya Smart app to scan the following QR code to complete the login.\n\nContinue to the next step once you have completed this step in the app."
},
"user": {
+10 -1
View File
@@ -3,7 +3,11 @@
import logging
from aiowebdav2.client import Client
from aiowebdav2.exceptions import UnauthorizedError
from aiowebdav2.exceptions import (
ConnectionExceptionError,
NoConnectionError,
UnauthorizedError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
@@ -35,6 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo
translation_domain=DOMAIN,
translation_key="invalid_username_password",
) from err
except (ConnectionExceptionError, NoConnectionError, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
# Check if we can connect to the WebDAV server
# and access the root directory
+12 -6
View File
@@ -5,6 +5,7 @@ import logging
from typing import Any
from weheat.abstractions.user import async_get_user_id_from_token
from weheat.exceptions import ApiException
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
@@ -33,12 +34,17 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Override create entry to find heat pumps."""
# get the user id and use that as unique id for this entry
user_id = await async_get_user_id_from_token(
API_URL,
data[CONF_TOKEN][CONF_ACCESS_TOKEN],
async_get_clientsession(self.hass),
)
try:
user_id = await async_get_user_id_from_token(
API_URL,
data[CONF_TOKEN][CONF_ACCESS_TOKEN],
async_get_clientsession(self.hass),
)
except ApiException as err:
self.logger.error("Failed to get user ID from Weheat API: %s", err)
return self.async_abort(reason="oauth_failed")
if user_id is None:
return self.async_abort(reason="oauth_failed")
await self.async_set_unique_id(user_id)
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured()
@@ -177,6 +177,6 @@ class XiaomiGatewayDevice(CoordinatorEntity[GatewayDeviceCoordinator], Entity):
manufacturer="Xiaomi",
name=self._sub_device.name,
model=self._sub_device.model,
sw_version=self._sub_device.firmware_version,
sw_version=str(self._sub_device.firmware_version),
hw_version=self._sub_device.zigbee_model,
)
@@ -432,15 +432,6 @@ async def async_set_credential(
translation_key="no_available_credential_slots",
translation_placeholders={"credential_type": cred_type_str},
)
elif not 1 <= credential_slot <= type_cap.number_of_credential_slots:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="credential_slot_out_of_range",
translation_placeholders={
"credential_type": cred_type_str,
"max_slot": str(type_cap.number_of_credential_slots),
},
)
status = await node.access_control.set_credential(
user_id, credential_type, credential_slot, credential_data
@@ -322,9 +322,6 @@
"credential_rejected_wrong_uuid": {
"message": "The device rejected the credential because the user unique identifier does not match."
},
"credential_slot_out_of_range": {
"message": "Credential slot for {credential_type} must be between 1 and {max_slot}."
},
"credential_type_not_supported": {
"message": "Credential type {credential_type} is not supported on this device"
},
+1 -1
View File
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 6
PATCH_VERSION: Final = "3"
PATCH_VERSION: Final = "4"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
+3 -3
View File
@@ -1,7 +1,7 @@
# Automatically generated by gen_requirements_all.py, do not edit
aiodhcpwatcher==1.2.7
aiodiscover==3.2.4
aiodiscover==3.3.2
aiodns==4.0.4
aiogithubapi==26.0.0
aiohttp-asyncmdnsresolver==0.2.0
@@ -25,7 +25,7 @@ bleak==3.0.2
bluetooth-adapters==2.3.0
bluetooth-auto-recovery==1.6.4
bluetooth-data-tools==1.29.18
cached-ipaddress==1.1.1
cached-ipaddress==1.1.2
certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
@@ -39,7 +39,7 @@ habluetooth==6.8.1
hass-nabucasa==2.2.0
hassil==3.7.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260527.6
home-assistant-frontend==20260527.7
home-assistant-intents==2026.6.1
httpx==0.28.1
ifaddr==0.2.0
+1 -1
View File
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
from typing import Final
FRONTEND_VERSION: Final[str] = "20260527.6"
FRONTEND_VERSION: Final[str] = "20260527.7"
MDI_ICONS: Final[set[str]] = {
"ab-testing",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.6.3"
version = "2026.6.4"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+12 -12
View File
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==14.0.3
aioamazondevices==14.1.3
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -230,13 +230,13 @@ aiobotocore==2.21.1
aiocentriconnect==0.2.3
# homeassistant.components.comelit
aiocomelit==2.0.3
aiocomelit==2.0.7
# homeassistant.components.dhcp
aiodhcpwatcher==1.2.7
# homeassistant.components.dhcp
aiodiscover==3.2.4
aiodiscover==3.3.2
# homeassistant.components.dnsip
aiodns==4.0.4
@@ -300,7 +300,7 @@ aiohue==4.8.1
aioimaplib==2.0.1
# homeassistant.components.immich
aioimmich==0.14.1
aioimmich==0.15.0
# homeassistant.components.apache_kafka
aiokafka==0.10.0
@@ -730,7 +730,7 @@ btsmarthub-devicelist==0.2.3
buienradar==1.0.6
# homeassistant.components.dhcp
cached-ipaddress==1.1.1
cached-ipaddress==1.1.2
# homeassistant.components.caldav
caldav==2.1.0
@@ -853,7 +853,7 @@ dremel3dpy==2.1.1
dropmqttapi==1.0.3
# homeassistant.components.dsmr
dsmr-parser==1.7.0
dsmr-parser==1.9.0
# homeassistant.components.dwd_weather_warnings
dwdwfsapi==1.0.7
@@ -1266,7 +1266,7 @@ hole==0.9.0
holidays==0.98
# homeassistant.components.frontend
home-assistant-frontend==20260527.6
home-assistant-frontend==20260527.7
# homeassistant.components.conversation
home-assistant-intents==2026.6.1
@@ -1513,7 +1513,7 @@ lupupy==0.3.2
lw12==0.9.2
# homeassistant.components.scrape
lxml==6.0.1
lxml==6.1.1
# homeassistant.components.matrix
matrix-nio==0.25.2
@@ -1776,7 +1776,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
opower==0.18.4
opower==0.18.5
# homeassistant.components.oralb
oralb-ble==1.1.0
@@ -2253,7 +2253,7 @@ pyisy==3.6.1
pyitachip2ir==0.0.7
# homeassistant.components.ituran
pyituran==0.1.5
pyituran==0.1.6
# homeassistant.components.jvc_projector
pyjvcprojector==2.0.6
@@ -2474,7 +2474,7 @@ pyqwikswitch==0.93
pyrail==0.4.1
# homeassistant.components.rainbird
pyrainbird==6.3.0
pyrainbird==6.3.1
# homeassistant.components.playstation_network
pyrate-limiter==4.1.0
@@ -2492,7 +2492,7 @@ pyrisco==0.7.0
pyrituals==0.0.7
# homeassistant.components.thread
pyroute2==0.7.5
pyroute2==0.9.6
# homeassistant.components.rympro
pyrympro==0.0.9
+2
View File
@@ -77,6 +77,7 @@ TEST_DEVICE_1 = AmazonDevice(
),
},
media_player_supported=True,
communication_settings={},
)
TEST_DEVICE_2_SN = "echo_test_2_serial_number"
@@ -109,6 +110,7 @@ TEST_DEVICE_2 = AmazonDevice(
notifications_supported=False,
notifications={},
media_player_supported=False,
communication_settings={},
)
TEST_VOCAL_RECORD_INITIAL = AmazonVocalRecord(
@@ -8,6 +8,8 @@
'AUDIO_PLAYER',
'MICROPHONE',
]),
'communication_settings': dict({
}),
'device_cluster_members': dict({
'echo_test_serial_number': 'echo_test_device_id',
}),
@@ -79,6 +81,8 @@
'AUDIO_PLAYER',
'MICROPHONE',
]),
'communication_settings': dict({
}),
'device_cluster_members': dict({
'echo_test_serial_number': 'echo_test_device_id',
}),
@@ -150,6 +154,8 @@
'AUDIO_PLAYER',
'MICROPHONE',
]),
'communication_settings': dict({
}),
'device_cluster_members': dict({
'echo_test_serial_number': 'echo_test_device_id',
}),
@@ -216,34 +216,15 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None:
async def test_single_closed_site_no_closed_date(
hass: HomeAssistant, single_site_closed_no_close_date_api: Mock
) -> None:
"""Test single closed site with no closed date."""
initial_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert initial_result.get("type") is FlowResultType.FORM
assert initial_result.get("step_id") == "user"
# Test filling in API key
"""Test single closed site with no closed date is filtered out."""
enter_api_key_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_API_TOKEN: API_KEY},
)
assert enter_api_key_result.get("type") is FlowResultType.FORM
assert enter_api_key_result.get("step_id") == "site"
select_site_result = await hass.config_entries.flow.async_configure(
enter_api_key_result["flow_id"],
{CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"},
)
# Show available sites
assert select_site_result.get("type") is FlowResultType.CREATE_ENTRY
assert select_site_result.get("title") == "Home"
data = select_site_result.get("data")
assert data
assert data[CONF_API_TOKEN] == API_KEY
assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH"
assert enter_api_key_result.get("step_id") == "user"
assert enter_api_key_result.get("errors") == {"api_token": "no_site"}
async def test_single_site_rejoin(
@@ -333,13 +314,9 @@ async def test_unknown_error(hass: HomeAssistant, api_error: Mock) -> None:
assert result.get("errors") == {"api_token": "unknown_error"}
async def test_site_deduplication(single_site_rejoin_api: Mock) -> None:
"""Test site deduplication."""
async def test_site_filtering(single_site_rejoin_api: Mock) -> None:
"""Test that closed sites are filtered out and remaining sites are deduplicated."""
filtered = filter_sites(single_site_rejoin_api.get_sites())
assert len(filtered) == 2
assert (
next(s for s in filtered if s.nmi == "11111111111").status == SiteStatus.ACTIVE
)
assert (
next(s for s in filtered if s.nmi == "11111111112").status == SiteStatus.CLOSED
)
assert len(filtered) == 1
assert filtered[0].nmi == "11111111111"
assert filtered[0].status == SiteStatus.ACTIVE
@@ -2396,3 +2396,47 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["result"].unique_id == dr.format_mac(MOCK_MAC)
async def test_reconfigure_no_unique_id_with_discovery(hass: HomeAssistant) -> None:
"""Test reconfigure succeeds when entry has no unique_id and device is discovered."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: f"elks://{MOCK_IP_ADDRESS}",
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_PREFIX: "",
},
unique_id=None,
)
config_entry.add_to_hass(hass)
result = await config_entry.start_reconfigure_flow(hass)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
mocked_elk = mock_elk(invalid_auth=False, sync_complete=True)
with (
_patch_discovery(),
_patch_elk(mocked_elk),
patch(
"homeassistant.components.elkm1.async_setup_entry",
return_value=True,
),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_PROTOCOL: "non-secure",
CONF_ADDRESS: MOCK_IP_ADDRESS,
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reconfigure_successful"
assert config_entry.unique_id == dr.format_mac(MOCK_MAC)
+3 -1
View File
@@ -138,10 +138,12 @@ def mock_growatt_v1_api():
}
# Called by total coordinator during refresh
# Note: V1 API returns current_power in kW; the coordinator
# converts it to W when mapping to invTodayPpv.
mock_v1_api.plant_energy_overview.return_value = {
"today_energy": 12.5,
"total_energy": 1250.0,
"current_power": 2500,
"current_power": 2.5,
}
# Called by switch/number entities during turn_on/turn_off/set_value
@@ -88,8 +88,8 @@
}),
]),
'total_coordinator': dict({
'current_power': 2500,
'invTodayPpv': 2500,
'current_power': 2.5,
'invTodayPpv': 2500.0,
'todayEnergy': 12.5,
'today_energy': 12.5,
'totalEnergy': 1250.0,
@@ -3881,7 +3881,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2500',
'state': '2500.0',
})
# ---
# name: test_min_sensors_v1_api[sensor.test_plant_total_total_money_today-entry]
@@ -15939,7 +15939,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2500',
'state': '2500.0',
})
# ---
# name: test_sph_sensors_v1_api[sensor.test_plant_total_total_money_today-entry]
@@ -169,7 +169,7 @@ async def test_sensor_coordinator_updates(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 25.0, # Changed from 12.5
"total_energy": 1250.0,
"current_power": 2500,
"current_power": 2.5,
}
# Trigger coordinator refresh
@@ -284,7 +284,7 @@ async def test_midnight_bounce_suppression(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0.1,
"total_energy": 1250.1,
"current_power": 500,
"current_power": 0.5,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
@@ -339,7 +339,7 @@ async def test_normal_reset_no_bounce(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0.1,
"total_energy": 1250.1,
"current_power": 500,
"current_power": 0.5,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
@@ -353,7 +353,7 @@ async def test_normal_reset_no_bounce(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 1.5,
"total_energy": 1251.5,
"current_power": 2000,
"current_power": 2.0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
@@ -452,7 +452,7 @@ async def test_midnight_bounce_repeated(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 0.2,
"total_energy": 1250.2,
"current_power": 1000,
"current_power": 1.0,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
@@ -486,7 +486,7 @@ async def test_non_total_increasing_sensor_unaffected_by_bounce_suppression(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 12.5,
"total_energy": 0,
"current_power": 2500,
"current_power": 2.5,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
@@ -500,7 +500,7 @@ async def test_non_total_increasing_sensor_unaffected_by_bounce_suppression(
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 12.5,
"total_energy": 1250.0,
"current_power": 2500,
"current_power": 2.5,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
+4 -4
View File
@@ -40,8 +40,8 @@ from homeassistant.setup import async_setup_component
from homeassistant.util.aiohttp import MockStreamReaderChunked
from .const import (
MOCK_ALBUM_WITH_ASSETS,
MOCK_ALBUM_WITHOUT_ASSETS,
ALBUM_DATA,
MOCK_ALBUM_ASSETS,
MOCK_FAVORITE_ASSETS,
MOCK_PEOPLE_ASSETS,
MOCK_TAGS_ASSETS,
@@ -80,8 +80,7 @@ def mock_config_entry() -> MockConfigEntry:
def mock_immich_albums() -> AsyncMock:
"""Mock the Immich server."""
mock = AsyncMock(spec=ImmichAlbums)
mock.async_get_all_albums.return_value = [MOCK_ALBUM_WITHOUT_ASSETS]
mock.async_get_album_info.return_value = MOCK_ALBUM_WITH_ASSETS
mock.async_get_all_albums.return_value = [ALBUM_DATA]
mock.async_add_assets_to_album.return_value = [
ImmichAddAssetsToAlbumResponse.from_dict(
{"id": "abcdef-0123456789", "success": True}
@@ -160,6 +159,7 @@ def mock_immich_search() -> AsyncMock:
"""Mock the Immich server."""
mock = AsyncMock(spec=ImmichSearch)
mock.async_get_all_favorites.return_value = MOCK_FAVORITE_ASSETS
mock.async_get_all_by_album_ids.return_value = MOCK_ALBUM_ASSETS
mock.async_get_all_by_person_ids.return_value = MOCK_PEOPLE_ASSETS
mock.async_get_all_by_tag_ids.return_value = MOCK_TAGS_ASSETS
return mock
+91 -93
View File
@@ -26,103 +26,101 @@ MOCK_CONFIG_ENTRY_DATA = {
CONF_VERIFY_SSL: False,
}
ALBUM_DATA = {
"id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6",
"albumName": "My Album",
"albumThumbnailAssetId": "0d03a7ad-ddc7-45a6-adee-68d322a6d2f5",
"albumUsers": [],
"assetCount": 1,
"assets": [],
"createdAt": "2025-05-11T10:13:22.799Z",
"hasSharedLink": False,
"isActivityEnabled": False,
"ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"owner": {
"id": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"email": "admin@immich.local",
"name": "admin",
"profileImagePath": "",
"avatarColor": "primary",
"profileChangedAt": "2025-05-11T10:07:46.866Z",
},
"shared": False,
"updatedAt": "2025-05-17T11:26:03.696Z",
}
MOCK_ALBUM_WITHOUT_ASSETS = ImmichAlbum.from_dict(ALBUM_DATA)
MOCK_ALBUM_WITH_ASSETS = ImmichAlbum.from_dict(
ALBUM_DATA = ImmichAlbum.from_dict(
{
**ALBUM_DATA,
"assets": [
{
"id": "2e94c203-50aa-4ad2-8e29-56dd74e0eff4",
"deviceAssetId": "web-filename.jpg-1675185639000",
"ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"deviceId": "WEB",
"libraryId": None,
"type": "IMAGE",
"originalPath": (
"upload/upload/e7ef5713-9dab-4bd4-b899"
"-715b0ca4379e/b4/b8/"
"b4b8ef00-8a6d-4056-91ff-7f86dc66e427.jpg"
),
"originalFileName": "filename.jpg",
"originalMimeType": "image/jpeg",
"thumbhash": "1igGFALX8mVGdHc5aChJf5nxNg==",
"fileCreatedAt": "2023-01-31T17:20:37.085+00:00",
"fileModifiedAt": "2023-01-31T17:20:39+00:00",
"localDateTime": "2023-01-31T18:20:37.085+00:00",
"updatedAt": "2025-05-11T10:13:49.590401+00:00",
"isFavorite": False,
"isArchived": False,
"isTrashed": False,
"duration": "0:00:00.00000",
"exifInfo": {},
"livePhotoVideoId": None,
"people": [],
"checksum": "HJm7TVOP80S+eiYZnAhWyRaB/Yc=",
"isOffline": False,
"hasMetadata": True,
"duplicateId": None,
"resized": True,
},
{
"id": "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b",
"deviceAssetId": "web-filename.mp4-1675185639000",
"ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"deviceId": "WEB",
"libraryId": None,
"type": "IMAGE",
"originalPath": (
"upload/upload/e7ef5713-9dab-4bd4-b899"
"-715b0ca4379e/b4/b8/"
"b4b8ef00-8a6d-4056-eeff-7f86dc66e427.mp4"
),
"originalFileName": "filename.mp4",
"originalMimeType": "video/mp4",
"thumbhash": "1igGFALX8mVGdHc5aChJf5nxNg==",
"fileCreatedAt": "2023-01-31T17:20:37.085+00:00",
"fileModifiedAt": "2023-01-31T17:20:39+00:00",
"localDateTime": "2023-01-31T18:20:37.085+00:00",
"updatedAt": "2025-05-11T10:13:49.590401+00:00",
"isFavorite": False,
"isArchived": False,
"isTrashed": False,
"duration": "0:00:00.00000",
"exifInfo": {},
"livePhotoVideoId": None,
"people": [],
"checksum": "HJm7TVOP80S+eiYZnAhWyRaB/Yc=",
"isOffline": False,
"hasMetadata": True,
"duplicateId": None,
"resized": True,
},
],
"id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6",
"albumName": "My Album",
"albumThumbnailAssetId": "0d03a7ad-ddc7-45a6-adee-68d322a6d2f5",
"albumUsers": [],
"assetCount": 1,
"createdAt": "2025-05-11T10:13:22.799Z",
"hasSharedLink": False,
"isActivityEnabled": False,
"ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"owner": {
"id": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"email": "admin@immich.local",
"name": "admin",
"profileImagePath": "",
"avatarColor": "primary",
"profileChangedAt": "2025-05-11T10:07:46.866Z",
},
"shared": False,
"updatedAt": "2025-05-17T11:26:03.696Z",
}
)
MOCK_ALBUM_ASSETS = [
ImmichAsset.from_dict(
{
"id": "2e94c203-50aa-4ad2-8e29-56dd74e0eff4",
"deviceAssetId": "web-filename.jpg-1675185639000",
"ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"deviceId": "WEB",
"libraryId": None,
"type": "IMAGE",
"originalPath": (
"upload/upload/e7ef5713-9dab-4bd4-b899"
"-715b0ca4379e/b4/b8/"
"b4b8ef00-8a6d-4056-91ff-7f86dc66e427.jpg"
),
"originalFileName": "filename.jpg",
"originalMimeType": "image/jpeg",
"thumbhash": "1igGFALX8mVGdHc5aChJf5nxNg==",
"fileCreatedAt": "2023-01-31T17:20:37.085+00:00",
"fileModifiedAt": "2023-01-31T17:20:39+00:00",
"localDateTime": "2023-01-31T18:20:37.085+00:00",
"updatedAt": "2025-05-11T10:13:49.590401+00:00",
"isFavorite": False,
"isArchived": False,
"isTrashed": False,
"duration": "0:00:00.00000",
"exifInfo": {},
"livePhotoVideoId": None,
"people": [],
"checksum": "HJm7TVOP80S+eiYZnAhWyRaB/Yc=",
"isOffline": False,
"hasMetadata": True,
"duplicateId": None,
"resized": True,
}
),
ImmichAsset.from_dict(
{
"id": "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b",
"deviceAssetId": "web-filename.mp4-1675185639000",
"ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"deviceId": "WEB",
"libraryId": None,
"type": "IMAGE",
"originalPath": (
"upload/upload/e7ef5713-9dab-4bd4-b899"
"-715b0ca4379e/b4/b8/"
"b4b8ef00-8a6d-4056-eeff-7f86dc66e427.mp4"
),
"originalFileName": "filename.mp4",
"originalMimeType": "video/mp4",
"thumbhash": "1igGFALX8mVGdHc5aChJf5nxNg==",
"fileCreatedAt": "2023-01-31T17:20:37.085+00:00",
"fileModifiedAt": "2023-01-31T17:20:39+00:00",
"localDateTime": "2023-01-31T18:20:37.085+00:00",
"updatedAt": "2025-05-11T10:13:49.590401+00:00",
"isFavorite": False,
"isArchived": False,
"isTrashed": False,
"duration": "0:00:00.00000",
"exifInfo": {},
"livePhotoVideoId": None,
"people": [],
"checksum": "HJm7TVOP80S+eiYZnAhWyRaB/Yc=",
"isOffline": False,
"hasMetadata": True,
"duplicateId": None,
"resized": True,
}
),
]
MOCK_PEOPLE_ASSETS = [
ImmichAsset.from_dict(
{
+1 -1
View File
@@ -293,7 +293,7 @@ async def test_browse_media_collections_error(
@pytest.mark.parametrize(
("collection", "mocked_get_fn"),
[
("albums", ("albums", "async_get_album_info")),
("albums", ("search", "async_get_all_by_album_ids")),
("favorites", ("search", "async_get_all_favorites")),
("people", ("search", "async_get_all_by_person_ids")),
("tags", ("search", "async_get_all_by_tag_ids")),
+32
View File
@@ -2,8 +2,11 @@
from unittest.mock import Mock, patch
from aiohttp import ContentTypeError, RequestInfo
from multidict import CIMultiDict, CIMultiDictProxy
import pytest
from syrupy.assertion import SnapshotAssertion
from yarl import URL
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -43,3 +46,32 @@ async def test_admin_sensors(
assert hass.states.get("sensor.mock_title_videos_count") is None
assert hass.states.get("sensor.mock_title_disk_used_by_photos") is None
assert hass.states.get("sensor.mock_title_disk_used_by_videos") is None
async def test_update_error_does_not_leak_api_key(
hass: HomeAssistant,
mock_immich: Mock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that API key is not leaked in error logs on connection failure."""
await setup_integration(hass, mock_config_entry)
api_key = "SECRET_API_KEY_12345"
headers = CIMultiDictProxy(
CIMultiDict({"x-api-key": api_key, "Host": "example.com"})
)
request_info = RequestInfo(
url=URL("https://example.com/api/server/about"),
method="GET",
headers=headers,
real_url=URL("https://example.com/api/server/about"),
)
mock_immich.server.async_get_about_info.side_effect = ContentTypeError(
request_info, (), status=503, message="Service Unavailable"
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert api_key not in caplog.text
+1 -1
View File
@@ -82,7 +82,7 @@ async def test_upload_file_to_album(
mock_immich.assets.async_upload_asset.assert_called_with("/media/screenshot.jpg")
mock_immich.albums.async_get_album_info.assert_called_with(
"721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", True
"721e1a4b-aa12-441e-8d3b-5ac7ab283bb6"
)
mock_immich.albums.async_add_assets_to_album.assert_called_with(
"721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", ["abcdef-0123456789"]
+47 -1
View File
@@ -13,7 +13,13 @@ import pytest
from homeassistant.components import influxdb
from homeassistant.components.influxdb.const import DEFAULT_BUCKET, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON, STATE_STANDBY
from homeassistant.const import (
CONF_PATH,
PERCENTAGE,
STATE_OFF,
STATE_ON,
STATE_STANDBY,
)
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
@@ -321,6 +327,46 @@ async def test_setup_config_ssl(
assert expected_client_args.items() <= mock_client.call_args.kwargs.items()
@pytest.mark.parametrize(
("mock_client", "config_ext", "expected_path"),
[
pytest.param(
influxdb.DEFAULT_API_VERSION,
{CONF_PATH: "/"},
None,
id="root_path_excluded",
),
pytest.param(
influxdb.DEFAULT_API_VERSION,
{CONF_PATH: "/custom_path"},
"/custom_path",
id="custom_path_included",
),
pytest.param(
influxdb.DEFAULT_API_VERSION,
{},
None,
id="no_path_excluded",
),
],
indirect=["mock_client"],
)
async def test_setup_config_path(
hass: HomeAssistant, mock_client, config_ext: dict, expected_path: str | None
) -> None:
"""Test that path='/' is not passed to InfluxDBClient, but other paths are."""
config = BASE_V1_CONFIG.copy()
config.update(config_ext)
mock_entry = MockConfigEntry(domain=DOMAIN, data=config)
mock_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_client.call_args.kwargs.get(CONF_PATH) == expected_path
@pytest.mark.parametrize(
("mock_client", "get_write_api", "config_ext"),
[
@@ -248,7 +248,7 @@
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'MR012345 Prebrew off time',
'max': 10,
'max': 30,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
@@ -269,7 +269,7 @@
]),
'area_id': None,
'capabilities': dict({
'max': 10,
'max': 30,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
@@ -309,7 +309,7 @@
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'MR012345 Prebrew on time',
'max': 10,
'max': 30,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
@@ -330,7 +330,7 @@
]),
'area_id': None,
'capabilities': dict({
'max': 10,
'max': 30,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
@@ -370,7 +370,7 @@
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'MR012345 Preinfusion time',
'max': 10,
'max': 30,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
@@ -391,7 +391,7 @@
]),
'area_id': None,
'capabilities': dict({
'max': 10,
'max': 30,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
+26
View File
@@ -109,6 +109,32 @@ async def test_fan_set_speed(
)
async def test_fan_set_percentage_no_op_when_already_at_target(
hass: HomeAssistant,
mock_miele_client: MagicMock,
setup_platform: MockConfigEntry,
) -> None:
"""Test that set_percentage is a no-op when already at the target step."""
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50},
blocking=True,
)
mock_miele_client.send_action.assert_called_once_with(
"DummyAppliance_18", {"ventilationStep": 2}
)
mock_miele_client.send_action.reset_mock()
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50},
blocking=True,
)
mock_miele_client.send_action.assert_not_called()
async def test_fan_turn_on_w_percentage(
hass: HomeAssistant,
mock_miele_client: MagicMock,
+2 -10
View File
@@ -625,9 +625,7 @@ async def test_hassio_confirm(
assert result["description_placeholders"] == {"addon": "Mosquitto Mqtt Broker"}
mock_try_connection_success.reset_mock()
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"discovery": True}
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].data == {
@@ -636,7 +634,6 @@ async def test_hassio_confirm(
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"discovery": True,
}
# Check we tried the connection
assert len(mock_try_connection_success.mock_calls)
@@ -673,9 +670,7 @@ async def test_hassio_cannot_connect(
assert result["description_placeholders"] == {"addon": "Mock Addon"}
mock_try_connection_time_out.reset_mock()
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"discovery": True}
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.FORM
assert result["errors"]["base"] == "cannot_connect"
@@ -732,7 +727,6 @@ async def test_addon_flow_with_supervisor_addon_running(
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"discovery": True,
}
# Check we tried the connection
assert len(mock_try_connection_success.mock_calls)
@@ -800,7 +794,6 @@ async def test_addon_flow_with_supervisor_addon_installed(
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"discovery": True,
}
# Check we tried the connection
assert len(mock_try_connection_success.mock_calls)
@@ -1040,7 +1033,6 @@ async def test_addon_flow_with_supervisor_addon_not_installed(
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"discovery": True,
}
# Check we tried the connection
assert len(mock_try_connection_success.mock_calls)
@@ -63,6 +63,7 @@ def mock_diffuser(
diffuser_mock.version = version
diffuser_mock.wifi_percentage = wifi_percentage
diffuser_mock.data = load_json_object_fixture("data.json", DOMAIN)
diffuser_mock.hub_data = diffuser_mock.data["hub"]
return diffuser_mock
@@ -71,6 +72,24 @@ def mock_diffuser_v1_battery_cartridge() -> MagicMock:
return mock_diffuser(hublot="lot123v1")
def mock_diffuser_v3_no_battery_no_fill() -> MagicMock:
"""Create and return a mock version 3 Diffuser without battery or fill sensor."""
diffuser = mock_diffuser(
hublot="lot123v3",
battery_percentage=Exception(),
charging=Exception(),
fill="",
has_battery=False,
has_cartridge=True,
name="Genie V3",
perfume="Ritual of Sakura",
version="6.0",
)
diffuser.data = load_json_object_fixture("data_no_fill.json", DOMAIN)
diffuser.hub_data = diffuser.data["hub"]
return diffuser
def mock_diffuser_v2_no_battery_no_cartridge() -> MagicMock:
"""Create and return a mock version 2 Diffuser without battery and cartridge."""
return mock_diffuser(
@@ -0,0 +1,70 @@
{
"hub": {
"hublot": "LOT456",
"hash": "abcdef1234567890ghijklmnopqrstuvwxyz",
"status": 1,
"title": null,
"current_time": "2023-06-09T20:50",
"cached_time": "2023-06-09T20:48",
"ping_update": "25",
"attributes": {
"roomc": "4",
"speedc": "3",
"fanc": "1",
"roomnamec": "Living room",
"resetc": "",
"fspacenamec": "",
"fspacetypec": ""
},
"sensors": {
"wific": {
"id": 10,
"sensor_id": 1,
"title": "High",
"description": "",
"icon": "icon-signal.png",
"image": "",
"discover_image": "",
"discover_url": null,
"min_value": "-69.99",
"max_value": "-0.00",
"interval": "1",
"created_at": "2017-03-10 16:17:30",
"updated_at": "2020-06-17 16:57:53",
"default": 0
},
"rfidc": {
"id": 54,
"sensor_id": 4,
"title": "Private Collection Sweet Jasmine",
"description": "",
"icon": "icon-jasmine.png",
"image": "background-jasmine.png",
"discover_image": "discover-jasmine.png",
"discover_url": "sweet-jasmine-cartridge-1105402.html",
"min_value": "05377650",
"max_value": "05377650",
"interval": "",
"created_at": "2019-04-04 07:53:32",
"updated_at": "2021-01-26 14:17:03",
"default": 0
},
"versionc": "6.0",
"ipc": "1682963060"
},
"settings": [
{
"schedule_id": 1835730,
"start": "07:30",
"end": "08:30",
"mon": 1,
"tue": 1,
"wed": 1,
"thu": 1,
"fri": 1,
"sat": 1,
"sun": 1
}
]
}
}
@@ -14,6 +14,7 @@ from .common import (
init_integration,
mock_config_entry,
mock_diffuser_v1_battery_cartridge,
mock_diffuser_v3_no_battery_no_fill,
)
@@ -63,3 +64,19 @@ async def test_sensors_diffuser_v1_battery_cartridge(
assert entry
assert entry.unique_id == f"{hublot}-wifi_percentage"
assert entry.entity_category == EntityCategory.DIAGNOSTIC
async def test_sensors_diffuser_v3_no_fill(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test that a Genie 3.0 without fill sensor data does not create a fill entity."""
config_entry = mock_config_entry(unique_id="id_123_sensor_test_diffuser_v3")
diffuser = mock_diffuser_v3_no_battery_no_fill()
await init_integration(hass, config_entry, [diffuser])
assert hass.states.get("sensor.genie_v3_fill") is None
assert entity_registry.async_get("sensor.genie_v3_fill") is None
state = hass.states.get("sensor.genie_v3_perfume")
assert state
assert state.state == diffuser.perfume
+15
View File
@@ -49,6 +49,21 @@ async def test_base_station_migration(
assert device_registry.async_get_device(identifiers=new_identifiers) is not None
async def test_base_station_model_is_string(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry: MockConfigEntry,
patch_simplisafe_api,
) -> None:
"""Test that the base station model is stored as a string in the device registry."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, "12345")})
assert device is not None
assert isinstance(device.model, str)
async def test_coordinator_update_triggers_reauth_on_invalid_credentials(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@@ -6,6 +6,14 @@
]),
'area_id': None,
'capabilities': dict({
'source_list': list([
'66 - Watercolors',
"Les P'tits Bateaux",
'James Taylor Radio',
'1984',
'American Tall Tales',
'sample playlist',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
@@ -49,6 +57,14 @@
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'source_list': list([
'66 - Watercolors',
"Les P'tits Bateaux",
'James Taylor Radio',
'1984',
'American Tall Tales',
'sample playlist',
]),
'supported_features': <MediaPlayerEntityFeature: 8321599>,
'volume_level': 0.19,
}),
+81 -8
View File
@@ -40,6 +40,7 @@ from homeassistant.components.media_player import (
SERVICE_PLAY_MEDIA,
SERVICE_SELECT_SOURCE,
MediaPlayerEnqueue,
MediaPlayerEntityFeature,
RepeatMode,
)
from homeassistant.components.sonos.const import (
@@ -723,6 +724,7 @@ async def test_play_sonos_playlist(
),
],
)
@pytest.mark.parametrize("speaker_model", ["Sonos Amp"], indirect=True)
async def test_select_source_line_in_tv(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
@@ -825,6 +827,7 @@ async def test_select_source_line_in_tv(
),
],
)
@pytest.mark.parametrize("speaker_model", ["Sonos Amp"], indirect=True)
async def test_select_source_play_uri(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
@@ -866,6 +869,7 @@ async def test_select_source_play_uri(
),
],
)
@pytest.mark.parametrize("speaker_model", ["Sonos Amp"], indirect=True)
async def test_select_source_play_queue(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
@@ -897,6 +901,7 @@ async def test_select_source_play_queue(
soco_mock.play_from_queue.assert_called_with(0)
@pytest.mark.parametrize("speaker_model", ["Sonos Amp"], indirect=True)
async def test_select_source_error(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
@@ -1425,16 +1430,26 @@ async def test_media_get_queue(
assert result == snapshot
FAVORITE_TITLES = [
"66 - Watercolors",
"Les P'tits Bateaux",
"James Taylor Radio",
"1984",
"American Tall Tales",
"sample playlist",
]
@pytest.mark.parametrize(
("speaker_model", "source_list"),
[
("Sonos Arc Ultra", [SOURCE_TV]),
("Sonos Arc", [SOURCE_TV]),
("Sonos Playbar", [SOURCE_TV]),
("Sonos Connect", [SOURCE_LINEIN]),
("Sonos Play:5", [SOURCE_LINEIN]),
("Sonos Amp", [SOURCE_LINEIN, SOURCE_TV]),
("Sonos Era", None),
("Sonos Arc Ultra", [SOURCE_TV, *FAVORITE_TITLES]),
("Sonos Arc", [SOURCE_TV, *FAVORITE_TITLES]),
("Sonos Playbar", [SOURCE_TV, *FAVORITE_TITLES]),
("Sonos Connect", [SOURCE_LINEIN, *FAVORITE_TITLES]),
("Sonos Play:5", [SOURCE_LINEIN, *FAVORITE_TITLES]),
("Sonos Amp", [SOURCE_LINEIN, SOURCE_TV, *FAVORITE_TITLES]),
("Sonos Era", FAVORITE_TITLES),
],
indirect=["speaker_model"],
)
@@ -1442,11 +1457,69 @@ async def test_media_source_list(
hass: HomeAssistant,
async_autosetup_sonos,
speaker_model: str,
source_list: list[str] | None,
source_list: list[str],
) -> None:
"""Test the mapping between the speaker model name and source_list."""
state = hass.states.get("media_player.zone_a")
assert state.attributes.get(ATTR_INPUT_SOURCE_LIST) == source_list
features = MediaPlayerEntityFeature(state.attributes["supported_features"])
assert features & MediaPlayerEntityFeature.SELECT_SOURCE
@pytest.mark.parametrize(
("speaker_model", "expected_sources", "expected_after_clear"),
[
("Model Name", FAVORITE_TITLES, None),
("Sonos Arc Ultra", [SOURCE_TV, *FAVORITE_TITLES], [SOURCE_TV]),
(
"Sonos Amp",
[SOURCE_LINEIN, SOURCE_TV, *FAVORITE_TITLES],
[SOURCE_LINEIN, SOURCE_TV],
),
],
indirect=["speaker_model"],
)
async def test_source_list_favorites_cleared(
hass: HomeAssistant,
soco: MockSoCo,
async_autosetup_sonos,
speaker_model: str,
expected_sources: list[str],
expected_after_clear: list[str] | None,
) -> None:
"""Test source_list and SELECT_SOURCE update when favorites are cleared."""
entity_id = "media_player.zone_a"
state = hass.states.get(entity_id)
assert state.attributes.get(ATTR_INPUT_SOURCE_LIST) == expected_sources
features = MediaPlayerEntityFeature(state.attributes["supported_features"])
assert features & MediaPlayerEntityFeature.SELECT_SOURCE
# Clear favorites via the music library mock
empty_favorites = SearchResult([], "favorites", 0, 0, 2)
soco.music_library.get_sonos_favorites.return_value = empty_favorites
soco.music_library.get_music_library_information.side_effect = None
soco.music_library.get_music_library_information.return_value = SearchResult(
[], "sonos_playlists", 0, 0, 0
)
# Trigger a favorites cache update via the content directory event
service = soco.contentDirectory
subscription = service.subscribe.return_value
favorites_event = SonosMockEvent(
soco,
service,
{"favorites_update_id": "2", "container_update_i_ds": "FV:2,2"},
)
subscription.callback(event=favorites_event)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state.attributes.get(ATTR_INPUT_SOURCE_LIST) == expected_after_clear
features = MediaPlayerEntityFeature(state.attributes["supported_features"])
assert bool(features & MediaPlayerEntityFeature.SELECT_SOURCE) == (
expected_after_clear is not None
)
async def test_service_update_alarm(
+19
View File
@@ -123,6 +123,25 @@ async def test_device_registry(
assert reg_device == snapshot
async def test_device_registry_numeric_firmware(
hass: HomeAssistant,
device_registry: DeviceRegistry,
config_entry: MockConfigEntry,
lms: MagicMock,
) -> None:
"""Test that numeric firmware values are stored as strings in the device registry."""
players = await lms.async_get_players()
players[0].firmware = 137
with patch("homeassistant.components.squeezebox.Server", return_value=lms):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[0])})
assert reg_device is not None
assert reg_device.hw_version == "137"
async def test_device_registry_server_merged(
hass: HomeAssistant,
device_registry: DeviceRegistry,
+13
View File
@@ -6,6 +6,7 @@ from homeassistant.components.subaru.const import (
API_GEN_1,
API_GEN_2,
API_GEN_3,
API_GEN_4,
VEHICLE_API_GEN,
VEHICLE_HAS_EV,
VEHICLE_HAS_REMOTE_SERVICE,
@@ -21,6 +22,7 @@ from homeassistant.components.subaru.const import (
TEST_VIN_1_G1 = "JF2ABCDE6L0000001"
TEST_VIN_2_EV = "JF2ABCDE6L0000002"
TEST_VIN_3_G3 = "JF2ABCDE6L0000003"
TEST_VIN_4_G4 = "JF2ABCDE6L0000004"
VEHICLE_DATA = {
TEST_VIN_1_G1: {
@@ -56,6 +58,17 @@ VEHICLE_DATA = {
VEHICLE_HAS_REMOTE_SERVICE: True,
VEHICLE_HAS_SAFETY_SERVICE: True,
},
TEST_VIN_4_G4: {
VEHICLE_VIN: TEST_VIN_4_G4,
VEHICLE_MODEL_YEAR: "2026",
VEHICLE_MODEL_NAME: "Outback",
VEHICLE_NAME: "test_vehicle_4",
VEHICLE_HAS_EV: False,
VEHICLE_API_GEN: API_GEN_4,
VEHICLE_HAS_REMOTE_START: True,
VEHICLE_HAS_REMOTE_SERVICE: True,
VEHICLE_HAS_SAFETY_SERVICE: True,
},
}
MOCK_DATETIME = datetime.fromtimestamp(1595560000, UTC)
+25
View File
@@ -12,12 +12,14 @@ from homeassistant.components.subaru.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from .api_responses import (
TEST_VIN_1_G1,
TEST_VIN_2_EV,
TEST_VIN_3_G3,
TEST_VIN_4_G4,
VEHICLE_DATA,
VEHICLE_STATUS_EV,
VEHICLE_STATUS_G3,
@@ -58,6 +60,29 @@ async def test_setup_g3(hass: HomeAssistant, subaru_config_entry) -> None:
assert check_entry.state is ConfigEntryState.LOADED
async def test_setup_g4(hass: HomeAssistant, subaru_config_entry) -> None:
"""Test setup with a G4 vehicle (2026+ models report api_gen "g4")."""
await setup_subaru_config_entry(
hass,
subaru_config_entry,
vehicle_list=[TEST_VIN_4_G4],
vehicle_data=VEHICLE_DATA[TEST_VIN_4_G4],
vehicle_status=VEHICLE_STATUS_G3,
)
check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id)
assert check_entry
assert check_entry.state is ConfigEntryState.LOADED
# Gen4 must receive both Gen2+ and Gen3+ sensor sets; without this, only
# the odometer was created on 2026 model year vehicles.
entity_registry = er.async_get(hass)
assert entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{TEST_VIN_4_G4}_AVG_FUEL_CONSUMPTION"
)
assert entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{TEST_VIN_4_G4}_REMAINING_FUEL_PERCENT"
)
async def test_setup_g1(hass: HomeAssistant, subaru_config_entry) -> None:
"""Test setup with a G1 vehicle."""
await setup_subaru_config_entry(
@@ -0,0 +1,52 @@
"""Test for the switchbot_cloud base entity."""
from unittest.mock import patch
from homeassistant.components.switchbot_cloud.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import METER_INFO, configure_integration
async def test_sw_version_cast_to_string(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_list_devices,
mock_get_status,
mock_setup_webhook,
) -> None:
"""Test the device sw_version is a string when the API returns an int."""
mock_list_devices.return_value = [METER_INFO]
mock_get_status.return_value = {"version": 123}
with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]):
await configure_integration(hass)
device = device_registry.async_get_device(
identifiers={(DOMAIN, METER_INFO.device_id)}
)
assert device is not None
assert device.sw_version == "123"
async def test_sw_version_none_when_missing(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_list_devices,
mock_get_status,
mock_setup_webhook,
) -> None:
"""Test the device sw_version is None when the API omits the version."""
mock_list_devices.return_value = [METER_INFO]
mock_get_status.return_value = {}
with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]):
await configure_integration(hass)
device = device_registry.async_get_device(
identifiers={(DOMAIN, METER_INFO.device_id)}
)
assert device is not None
assert device.sw_version is None
+28 -2
View File
@@ -2,7 +2,12 @@
from unittest.mock import AsyncMock
from aiowebdav2.exceptions import AccessDeniedError, UnauthorizedError
from aiowebdav2.exceptions import (
AccessDeniedError,
ConnectionExceptionError,
NoConnectionError,
UnauthorizedError,
)
import pytest
from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN
@@ -28,8 +33,29 @@ from tests.common import MockConfigEntry
"Access denied to /access_denied",
ConfigEntryState.SETUP_ERROR,
),
(
ConnectionExceptionError(ConnectionError("Connection refused")),
"Connection refused",
ConfigEntryState.SETUP_RETRY,
),
(
NoConnectionError("webdav.demo"),
"No connection with webdav.demo",
ConfigEntryState.SETUP_RETRY,
),
(
TimeoutError(),
"",
ConfigEntryState.SETUP_RETRY,
),
],
ids=[
"UnauthorizedError",
"AccessDeniedError",
"ConnectionExceptionError",
"NoConnectionError",
"TimeoutError",
],
ids=["UnauthorizedError", "AccessDeniedError"],
)
async def test_error_during_setup(
hass: HomeAssistant,
@@ -3,6 +3,7 @@
from unittest.mock import AsyncMock, patch
import pytest
from weheat.exceptions import ApiException
from homeassistant.components.weheat.const import (
DOMAIN,
@@ -144,6 +145,35 @@ async def test_reauth(
assert entry.unique_id == USER_UUID_1
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.parametrize(
"side_effect",
[ApiException(status=500, reason="Internal Server Error"), None],
)
async def test_api_error_during_create_entry(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
side_effect: Exception | None,
) -> None:
"""Test config flow aborts when the API call fails or returns no user."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
)
await handle_oauth(hass, hass_client_no_auth, aioclient_mock, result)
with patch(
"homeassistant.components.weheat.config_flow.async_get_user_id_from_token",
side_effect=side_effect,
return_value=None,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "oauth_failed"
async def handle_oauth(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,

Some files were not shown because too many files have changed in this diff Show More