* Fix blocking event loop - daikin (#141442)

* fix blocking event loop

* create ssl_context directly

* update manifest

* update manifest.json

* Made Google Search enable dependent on Assist availability (#141712)

* Made Google Search enable dependent on Assist availability

* Show error instead of rendering again

* Cleanup test code

* Fix humidifier platform for Comelit (#141854)

* Fix humidifier platform for Comelit

* apply review comment

* Bump evohome-async to 1.0.5 (#141871)

bump client to 1.0.5

* Replace "to log into" with "to log in to" in `incomfort` (#142060)

* Replace "to log into" with "to log in to" in `incomfort`

Also fix one missing sentence-casing of "gateway".

* Replace duplicate "data_description" strings with references

* Avoid unnecessary reload in apple_tv reauth flow (#142079)

* Add translation for hassio update entity name (#142090)

* Bump pyenphase to 1.25.5 (#142107)

* Hide broken ZBT-1 config entries on the hardware page (#142110)

* Hide bad ZBT-1 config entries on the hardware page

* Set up the bad config entry in the unit test

* Roll into a list comprehension

* Remove constant changes

* Fix condition in unit test

* Bump pysmhi to 1.0.1 (#142111)

* Avoid logging a warning when replacing an ignored config entry (#142114)

Replacing an ignored config entry with one from the user
flow should not generate a warning. We should only warn
if we are replacing a usable config entry.

Followup to adjust the warning added in #130567
cc @epenet

* Slow down polling in Tesla Fleet (#142130)

* Slow down polling

* Fix tests

* Bump tesla-fleet-api to v1.0.17 (#142131)

bump

* Tado bump to 0.18.11 (#142175)

* Bump to version 0.18.11

* Adding hassfest files

* Add preset mode to SmartThings climate (#142180)

* Add preset mode to SmartThings climate

* Add preset mode to SmartThings climate

* Do not create a HA mediaplayer for the builtin Music Assistant player (#142192)

Do not create a HA mediaplayer for the builtin Music player

* Do not fetch disconnected Home Connect appliances (#142200)

* Do not fetch disconnected Home Connect appliances

* Apply suggestions

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update docstring

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Fix fibaro setup (#142201)

* Fix circular mean by always storing and using the weighted one (#142208)

* Fix circular mean by always storing and using the weighted one

* fix

* Fix test

* Bump pySmartThings to 3.0.2 (#142257)

Co-authored-by: Robert Resch <robert@resch.dev>

* Update frontend to 20250404.0 (#142274)

* Bump forecast-solar lib to v4.1.0 (#142280)

Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>

* Bump version to 2025.4.1

* Fix skyconnect tests (#142262)

fix tests

* Fix empty actions (#142292)

* Apply fix

* Add tests for alarm button cover lock

* update light

* add number tests

* test select

* add switch tests

* test vacuum

* update lock test

---------

Co-authored-by: Fredrik Erlandsson <fredrik.e@gmail.com>
Co-authored-by: Ivan Lopez Hernandez <ivan.lh.94@outlook.com>
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
Co-authored-by: David Bonnes <zxdavb@bonnes.me>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: Arie Catsman <120491684+catsmanac@users.noreply.github.com>
Co-authored-by: puddly <32534428+puddly@users.noreply.github.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Brett Adams <Bre77@users.noreply.github.com>
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
Co-authored-by: J. Diego Rodríguez Royo <jdrr1998@hotmail.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: rappenze <rappenze@yahoo.com>
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Klaas Schoute <klaas_schoute@hotmail.com>
Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Petro31 <35082313+Petro31@users.noreply.github.com>
This commit is contained in:
Franck Nijhof 2025-04-04 22:59:10 +02:00 committed by GitHub
commit 7af6a4f493
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 1097 additions and 229 deletions

View File

@ -20,6 +20,7 @@ import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_REAUTH,
SOURCE_ZEROCONF,
ConfigEntry,
ConfigFlow,
@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_IDENTIFIERS: list(combined_identifiers),
},
)
if entry.source != SOURCE_IGNORE:
# Don't reload ignored entries or in the middle of reauth,
# e.g. if the user is entering a new PIN
if entry.source != SOURCE_IGNORE and self.source != SOURCE_REAUTH:
self.hass.config_entries.async_schedule_reload(entry.entry_id)
if not allow_exist:
raise DeviceAlreadyConfigured

View File

@ -162,7 +162,7 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
if self.mode == HumidifierComelitMode.OFF:
if not self._attr_is_on:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="humidity_while_off",
@ -190,9 +190,13 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
await self.coordinator.api.set_humidity_status(
self._device.index, self._set_command
)
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off."""
await self.coordinator.api.set_humidity_status(
self._device.index, HumidifierComelitCommand.OFF
)
self._attr_is_on = False
self.async_write_ha_state()

View File

@ -52,7 +52,9 @@
"rest": "Rest",
"sabotated": "Sabotated"
}
}
},
"humidifier": {
"humidifier": {
"name": "Humidifier"
},

View File

@ -21,6 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
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 CONNECTION_NETWORK_MAC
from homeassistant.util.ssl import client_context_no_verify
from .const import KEY_MAC, TIMEOUT
from .coordinator import DaikinConfigEntry, DaikinCoordinator
@ -48,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
key=entry.data.get(CONF_API_KEY),
uuid=entry.data.get(CONF_UUID),
password=entry.data.get(CONF_PASSWORD),
ssl_context=client_context_no_verify(),
)
_LOGGER.debug("Connection to %s successful", host)
except TimeoutError as err:

View File

@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.14.1"],
"requirements": ["pydaikin==2.15.0"],
"zeroconf": ["_dkapi._tcp.local."]
}

View File

@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"requirements": ["pyenphase==1.25.1"],
"requirements": ["pyenphase==1.25.5"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
"quality_scale": "legacy",
"requirements": ["evohome-async==1.0.4"]
"requirements": ["evohome-async==1.0.5"]
}

View File

@ -301,6 +301,7 @@ class FibaroController:
device.ha_id = (
f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}"
)
platform = None
if device.enabled and (not device.is_plugin or self._import_plugins):
platform = self._map_device_to_platform(device)
if platform is None:

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["forecast-solar==4.0.0"]
"requirements": ["forecast-solar==4.1.0"]
}

View File

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

View File

@ -179,28 +179,30 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the options."""
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
errors: dict[str, str] = {}
if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none":
user_input.pop(CONF_LLM_HASS_API)
if not (
user_input.get(CONF_LLM_HASS_API, "none") != "none"
and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
):
# Don't allow to save options that enable the Google Seearch tool with an Assist API
return self.async_create_entry(title="", data=user_input)
errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option"
# Re-render the options again, now with the recommended options shown/hidden
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT],
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
}
options = user_input
schema = await google_generative_ai_config_option_schema(
self.hass, options, self._genai_client
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(schema),
step_id="init", data_schema=vol.Schema(schema), errors=errors
)

View File

@ -43,6 +43,9 @@
"prompt": "Instruct how the LLM should respond. This can be a template."
}
}
},
"error": {
"invalid_google_search_option": "Google Search cannot be enabled alongside any Assist capability, this can only be used when Assist is set to \"No control\"."
}
},
"services": {

View File

@ -265,6 +265,11 @@
"version_latest": {
"name": "Newest version"
}
},
"update": {
"update": {
"name": "[%key:component::update::title%]"
}
}
},
"services": {

View File

@ -39,7 +39,7 @@ from .entity import (
from .update_helper import update_addon, update_core
ENTITY_DESCRIPTION = UpdateEntityDescription(
name="Update",
translation_key="update",
key=ATTR_VERSION_LATEST,
)

View File

@ -73,6 +73,19 @@ class HomeConnectApplianceData:
self.settings.update(other.settings)
self.status.update(other.status)
@classmethod
def empty(cls, appliance: HomeAppliance) -> HomeConnectApplianceData:
"""Return empty data."""
return cls(
commands=set(),
events={},
info=appliance,
options={},
programs=[],
settings={},
status={},
)
class HomeConnectCoordinator(
DataUpdateCoordinator[dict[str, HomeConnectApplianceData]]
@ -358,15 +371,7 @@ class HomeConnectCoordinator(
model=appliance.vib,
)
if appliance.ha_id not in self.data:
self.data[appliance.ha_id] = HomeConnectApplianceData(
commands=set(),
events={},
info=appliance,
options={},
programs=[],
settings={},
status={},
)
self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance)
else:
self.data[appliance.ha_id].info.connected = appliance.connected
old_appliances.remove(appliance.ha_id)
@ -402,6 +407,15 @@ class HomeConnectCoordinator(
name=appliance.name,
model=appliance.vib,
)
if not appliance.connected:
_LOGGER.debug(
"Appliance %s is not connected, skipping data fetch",
appliance.ha_id,
)
if appliance_data_to_update:
appliance_data_to_update.info.connected = False
return appliance_data_to_update
return HomeConnectApplianceData.empty(appliance)
try:
settings = {
setting.key: setting

View File

@ -5,17 +5,21 @@ from __future__ import annotations
from homeassistant.components.hardware.models import HardwareInfo, USBInfo
from homeassistant.core import HomeAssistant, callback
from .config_flow import HomeAssistantSkyConnectConfigFlow
from .const import DOMAIN
from .util import get_hardware_variant
DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/"
EXPECTED_ENTRY_VERSION = (
HomeAssistantSkyConnectConfigFlow.VERSION,
HomeAssistantSkyConnectConfigFlow.MINOR_VERSION,
)
@callback
def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
"""Return board info."""
entries = hass.config_entries.async_entries(DOMAIN)
return [
HardwareInfo(
board=None,
@ -31,4 +35,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
url=DOCUMENTATION_URL,
)
for entry in entries
# Ignore unmigrated config entries in the hardware page
if (entry.version, entry.minor_version) == EXPECTED_ENTRY_VERSION
]

View File

@ -10,8 +10,8 @@
},
"data_description": {
"host": "Hostname or IP-address of the Intergas gateway.",
"username": "The username to log into the gateway. This is `admin` in most cases.",
"password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices."
"username": "The username to log in to the gateway. This is `admin` in most cases.",
"password": "The password to log in to the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices."
}
},
"dhcp_auth": {
@ -22,8 +22,8 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "The username to log into the gateway. This is `admin` in most cases.",
"password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices."
"username": "[%key:component::incomfort::config::step::user::data_description::username%]",
"password": "[%key:component::incomfort::config::step::user::data_description::password%]"
}
},
"dhcp_confirm": {

View File

@ -151,6 +151,8 @@ async def async_setup_entry(
assert event.object_id is not None
if event.object_id in added_ids:
return
if not player.expose_to_ha:
return
added_ids.add(event.object_id)
async_add_entities([MusicAssistantPlayer(mass, event.object_id)])
@ -159,6 +161,8 @@ async def async_setup_entry(
mass_players = []
# add all current players
for player in mass.players:
if not player.expose_to_ha:
continue
added_ids.add(player.player_id)
mass_players.append(MusicAssistantPlayer(mass, player.player_id))

View File

@ -139,14 +139,13 @@ def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]:
# in Python.
# https://en.wikipedia.org/wiki/Circular_mean
radians = func.radians(table.mean)
weighted_sum_sin = func.sum(func.sin(radians) * table.mean_weight)
weighted_sum_cos = func.sum(func.cos(radians) * table.mean_weight)
weight = func.sqrt(
func.power(func.sum(func.sin(radians) * table.mean_weight), 2)
+ func.power(func.sum(func.cos(radians) * table.mean_weight), 2)
func.power(weighted_sum_sin, 2) + func.power(weighted_sum_cos, 2)
)
return (
func.degrees(
func.atan2(func.sum(func.sin(radians)), func.sum(func.cos(radians)))
).label("mean"),
func.degrees(func.atan2(weighted_sum_sin, weighted_sum_cos)).label("mean"),
weight.label("mean_weight"),
)
@ -240,18 +239,20 @@ DEG_TO_RAD = math.pi / 180
RAD_TO_DEG = 180 / math.pi
def weighted_circular_mean(values: Iterable[tuple[float, float]]) -> float:
"""Return the weighted circular mean of the values."""
sin_sum = sum(math.sin(x * DEG_TO_RAD) * weight for x, weight in values)
cos_sum = sum(math.cos(x * DEG_TO_RAD) * weight for x, weight in values)
return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360
def weighted_circular_mean(
values: Iterable[tuple[float, float]],
) -> tuple[float, float]:
"""Return the weighted circular mean and the weight of the values."""
weighted_sin_sum, weighted_cos_sum = 0.0, 0.0
for x, weight in values:
rad_x = x * DEG_TO_RAD
weighted_sin_sum += math.sin(rad_x) * weight
weighted_cos_sum += math.cos(rad_x) * weight
def circular_mean(values: list[float]) -> float:
"""Return the circular mean of the values."""
sin_sum = sum(math.sin(x * DEG_TO_RAD) for x in values)
cos_sum = sum(math.cos(x * DEG_TO_RAD) for x in values)
return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360
return (
(RAD_TO_DEG * math.atan2(weighted_sin_sum, weighted_cos_sum)) % 360,
math.sqrt(weighted_sin_sum**2 + weighted_cos_sum**2),
)
_LOGGER = logging.getLogger(__name__)
@ -300,6 +301,7 @@ class StatisticsRow(BaseStatisticsRow, total=False):
min: float | None
max: float | None
mean: float | None
mean_weight: float | None
change: float | None
@ -1023,7 +1025,7 @@ def _reduce_statistics(
_want_sum = "sum" in types
for statistic_id, stat_list in stats.items():
max_values: list[float] = []
mean_values: list[float] = []
mean_values: list[tuple[float, float]] = []
min_values: list[float] = []
prev_stat: StatisticsRow = stat_list[0]
fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds}
@ -1039,12 +1041,15 @@ def _reduce_statistics(
}
if _want_mean:
row["mean"] = None
row["mean_weight"] = None
if mean_values:
match metadata[statistic_id][1]["mean_type"]:
case StatisticMeanType.ARITHMETIC:
row["mean"] = mean(mean_values)
row["mean"] = mean([x[0] for x in mean_values])
case StatisticMeanType.CIRCULAR:
row["mean"] = circular_mean(mean_values)
row["mean"], row["mean_weight"] = (
weighted_circular_mean(mean_values)
)
mean_values.clear()
if _want_min:
row["min"] = min(min_values) if min_values else None
@ -1063,7 +1068,8 @@ def _reduce_statistics(
max_values.append(_max)
if _want_mean:
if (_mean := statistic.get("mean")) is not None:
mean_values.append(_mean)
_mean_weight = statistic.get("mean_weight") or 0.0
mean_values.append((_mean, _mean_weight))
if _want_min and (_min := statistic.get("min")) is not None:
min_values.append(_min)
prev_stat = statistic
@ -1385,7 +1391,7 @@ def _get_max_mean_min_statistic(
match metadata[1]["mean_type"]:
case StatisticMeanType.CIRCULAR:
if circular_means := max_mean_min["circular_means"]:
mean_value = weighted_circular_mean(circular_means)
mean_value = weighted_circular_mean(circular_means)[0]
case StatisticMeanType.ARITHMETIC:
if (mean_value := max_mean_min.get("mean_acc")) is not None and (
duration := max_mean_min.get("duration")
@ -1739,12 +1745,12 @@ def statistic_during_period(
_type_column_mapping = {
"last_reset": "last_reset_ts",
"max": "max",
"mean": "mean",
"min": "min",
"state": "state",
"sum": "sum",
"last_reset": ("last_reset_ts",),
"max": ("max",),
"mean": ("mean", "mean_weight"),
"min": ("min",),
"state": ("state",),
"sum": ("sum",),
}
@ -1756,7 +1762,8 @@ def _generate_select_columns_for_types_stmt(
track_on: list[str | None] = [
table.__tablename__, # type: ignore[attr-defined]
]
for key, column in _type_column_mapping.items():
for key, type_columns in _type_column_mapping.items():
for column in type_columns:
if key in types:
columns = columns.add_columns(getattr(table, column))
track_on.append(column)
@ -1944,6 +1951,12 @@ def _statistics_during_period_with_session(
hass, session, start_time, units, _types, table, metadata, result
)
# filter out mean_weight as it is only needed to reduce statistics
# and not needed in the result
for stats_rows in result.values():
for row in stats_rows:
row.pop("mean_weight", None)
# Return statistics combined with metadata
return result
@ -2391,7 +2404,12 @@ def _sorted_statistics_to_dict(
field_map["last_reset"] = field_map.pop("last_reset_ts")
sum_idx = field_map["sum"] if "sum" in types else None
sum_only = len(types) == 1 and sum_idx is not None
row_mapping = tuple((key, field_map[key]) for key in types if key in field_map)
row_mapping = tuple(
(column, field_map[column])
for key in types
for column in ({key, *_type_column_mapping.get(key, ())})
if column in field_map
)
# Append all statistic entries, and optionally do unit conversion
table_duration_seconds = table.duration.total_seconds()
for meta_id, db_rows in stats_by_meta_id.items():

View File

@ -160,7 +160,7 @@ def _time_weighted_arithmetic_mean(
def _time_weighted_circular_mean(
fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime
) -> float:
) -> tuple[float, float]:
"""Calculate a time weighted circular mean.
The circular mean is calculated by weighting the states by duration in seconds between
@ -623,7 +623,7 @@ def compile_statistics( # noqa: C901
valid_float_states, start, end
)
case StatisticMeanType.CIRCULAR:
stat["mean"] = _time_weighted_circular_mean(
stat["mean"], stat["mean_weight"] = _time_weighted_circular_mean(
valid_float_states, start, end
)

View File

@ -333,7 +333,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
"""Define a SmartThings Air Conditioner."""
_attr_name = None
_attr_preset_mode = None
def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Init the class."""
@ -545,6 +544,18 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
SWING_OFF,
)
@property
def preset_mode(self) -> str | None:
"""Return the preset mode."""
if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE):
mode = self.get_attribute_value(
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
Attribute.AC_OPTIONAL_MODE,
)
if mode == WINDFREE:
return WINDFREE
return None
def _determine_preset_modes(self) -> list[str] | None:
"""Return a list of available preset modes."""
if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE):

View File

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

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smhi",
"iot_class": "cloud_polling",
"loggers": ["pysmhi"],
"requirements": ["pysmhi==1.0.0"]
"requirements": ["pysmhi==1.0.1"]
}

View File

@ -14,5 +14,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["PyTado"],
"requirements": ["python-tado==0.18.9"]
"requirements": ["python-tado==0.18.11"]
}

View File

@ -214,7 +214,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore
),
(CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER),
):
if action_config := config.get(action_id):
# Scripts can be an empty list, therefore we need to check for None
if (action_config := config.get(action_id)) is not None:
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature

View File

@ -120,7 +120,8 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity):
"""Initialize the button."""
super().__init__(hass, config=config, unique_id=unique_id)
assert self._attr_name is not None
if action := config.get(CONF_PRESS):
# Scripts can be an empty list, therefore we need to check for None
if (action := config.get(CONF_PRESS)) is not None:
self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_state = None

View File

@ -172,7 +172,8 @@ class CoverTemplate(TemplateEntity, CoverEntity):
(POSITION_ACTION, CoverEntityFeature.SET_POSITION),
(TILT_ACTION, TILT_FEATURES),
):
if action_config := config.get(action_id):
# Scripts can be an empty list, therefore we need to check for None
if (action_config := config.get(action_id)) is not None:
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature

View File

@ -157,7 +157,8 @@ class TemplateFan(TemplateEntity, FanEntity):
CONF_SET_OSCILLATING_ACTION,
CONF_SET_DIRECTION_ACTION,
):
if action_config := config.get(action_id):
# Scripts can be an empty list, therefore we need to check for None
if (action_config := config.get(action_id)) is not None:
self.add_script(action_id, action_config, name, DOMAIN)
self._state: bool | None = False

View File

@ -296,7 +296,8 @@ class LightTemplate(TemplateEntity, LightEntity):
self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION)
for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION):
if action_config := config.get(action_id):
# Scripts can be an empty list, therefore we need to check for None
if (action_config := config.get(action_id)) is not None:
self.add_script(action_id, action_config, name, DOMAIN)
self._state = False
@ -323,7 +324,8 @@ class LightTemplate(TemplateEntity, LightEntity):
(CONF_RGBW_ACTION, ColorMode.RGBW),
(CONF_RGBWW_ACTION, ColorMode.RGBWW),
):
if action_config := config.get(action_id):
# Scripts can be an empty list, therefore we need to check for None
if (action_config := config.get(action_id)) is not None:
self.add_script(action_id, action_config, name, DOMAIN)
color_modes.add(color_mode)
self._supported_color_modes = filter_supported_color_modes(color_modes)
@ -333,7 +335,7 @@ class LightTemplate(TemplateEntity, LightEntity):
self._color_mode = next(iter(self._supported_color_modes))
self._attr_supported_features = LightEntityFeature(0)
if self._action_scripts.get(CONF_EFFECT_ACTION):
if (self._action_scripts.get(CONF_EFFECT_ACTION)) is not None:
self._attr_supported_features |= LightEntityFeature.EFFECT
if self._supports_transition is True:
self._attr_supported_features |= LightEntityFeature.TRANSITION

View File

@ -98,7 +98,8 @@ class TemplateLock(TemplateEntity, LockEntity):
(CONF_UNLOCK, 0),
(CONF_OPEN, LockEntityFeature.OPEN),
):
if action_config := config.get(action_id):
# Scripts can be an empty list, therefore we need to check for None
if (action_config := config.get(action_id)) is not None:
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE)

View File

@ -141,7 +141,8 @@ class TemplateSelect(TemplateEntity, SelectEntity):
super().__init__(hass, config=config, unique_id=unique_id)
assert self._attr_name is not None
self._value_template = config[CONF_STATE]
if select_option := config.get(CONF_SELECT_OPTION):
# Scripts can be an empty list, therefore we need to check for None
if (select_option := config.get(CONF_SELECT_OPTION)) is not None:
self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN)
self._options_template = config[ATTR_OPTIONS]
self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False)
@ -197,7 +198,8 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity):
) -> None:
"""Initialize the entity."""
super().__init__(hass, coordinator, config)
if select_option := config.get(CONF_SELECT_OPTION):
# Scripts can be an empty list, therefore we need to check for None
if (select_option := config.get(CONF_SELECT_OPTION)) is not None:
self.add_script(
CONF_SELECT_OPTION,
select_option,

View File

@ -226,9 +226,10 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
assert name is not None
self._template = config.get(CONF_STATE)
if on_action := config.get(CONF_TURN_ON):
# Scripts can be an empty list, therefore we need to check for None
if (on_action := config.get(CONF_TURN_ON)) is not None:
self.add_script(CONF_TURN_ON, on_action, name, DOMAIN)
if off_action := config.get(CONF_TURN_OFF):
if (off_action := config.get(CONF_TURN_OFF)) is not None:
self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN)
self._state: bool | None = False

View File

@ -158,7 +158,8 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity):
(SERVICE_LOCATE, VacuumEntityFeature.LOCATE),
(SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED),
):
if action_config := config.get(action_id):
# Scripts can be an empty list, therefore we need to check for None
if (action_config := config.get(action_id)) is not None:
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature

View File

@ -27,7 +27,7 @@ if TYPE_CHECKING:
from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState
VEHICLE_INTERVAL_SECONDS = 300
VEHICLE_INTERVAL_SECONDS = 600
VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS)
VEHICLE_WAIT = timedelta(minutes=15)

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==1.0.16"]
"requirements": ["tesla-fleet-api==1.0.17"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==1.0.16", "teslemetry-stream==0.6.12"]
"requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.6.12"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tessie",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.16"]
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.17"]
}

View File

@ -1626,7 +1626,11 @@ class ConfigEntriesFlowManager(
result["handler"], flow.unique_id
)
if existing_entry is not None and flow.handler != "mobile_app":
if (
existing_entry is not None
and flow.handler != "mobile_app"
and existing_entry.source != SOURCE_IGNORE
):
# This causes the old entry to be removed and replaced, when the flow
# should instead be aborted.
# In case of manual flows, integrations should implement options, reauth,

View File

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

View File

@ -38,7 +38,7 @@ habluetooth==3.37.0
hass-nabucasa==0.94.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250401.0
home-assistant-frontend==20250404.0
home-assistant-intents==2025.3.28
httpx==0.28.1
ifaddr==0.2.0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.4.0"
version = "2025.4.1"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."

18
requirements_all.txt generated
View File

@ -901,7 +901,7 @@ eufylife-ble-client==0.1.8
# evdev==1.6.1
# homeassistant.components.evohome
evohome-async==1.0.4
evohome-async==1.0.5
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@ -954,7 +954,7 @@ fnv-hash-fast==1.4.0
foobot_async==1.0.0
# homeassistant.components.forecast_solar
forecast-solar==4.0.0
forecast-solar==4.1.0
# homeassistant.components.fortios
fortiosapi==1.0.5
@ -1157,7 +1157,7 @@ hole==0.8.0
holidays==0.69
# homeassistant.components.frontend
home-assistant-frontend==20250401.0
home-assistant-frontend==20250404.0
# homeassistant.components.conversation
home-assistant-intents==2025.3.28
@ -1891,7 +1891,7 @@ pycsspeechtts==1.0.8
# pycups==2.0.4
# homeassistant.components.daikin
pydaikin==2.14.1
pydaikin==2.15.0
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
@ -1948,7 +1948,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
pyenphase==1.25.1
pyenphase==1.25.5
# homeassistant.components.envisalink
pyenvisalink==4.7
@ -2319,13 +2319,13 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
pysmartthings==3.0.1
pysmartthings==3.0.2
# homeassistant.components.smarty
pysmarty2==0.10.2
# homeassistant.components.smhi
pysmhi==1.0.0
pysmhi==1.0.1
# homeassistant.components.edl21
pysml==0.0.12
@ -2482,7 +2482,7 @@ python-snoo==0.6.5
python-songpal==0.16.2
# homeassistant.components.tado
python-tado==0.18.9
python-tado==0.18.11
# homeassistant.components.technove
python-technove==2.0.0
@ -2878,7 +2878,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
tesla-fleet-api==1.0.16
tesla-fleet-api==1.0.17
# homeassistant.components.powerwall
tesla-powerwall==0.5.2

View File

@ -768,7 +768,7 @@ eternalegypt==0.0.16
eufylife-ble-client==0.1.8
# homeassistant.components.evohome
evohome-async==1.0.4
evohome-async==1.0.5
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@ -814,7 +814,7 @@ fnv-hash-fast==1.4.0
foobot_async==1.0.0
# homeassistant.components.forecast_solar
forecast-solar==4.0.0
forecast-solar==4.1.0
# homeassistant.components.freebox
freebox-api==1.2.2
@ -984,7 +984,7 @@ hole==0.8.0
holidays==0.69
# homeassistant.components.frontend
home-assistant-frontend==20250401.0
home-assistant-frontend==20250404.0
# homeassistant.components.conversation
home-assistant-intents==2025.3.28
@ -1548,7 +1548,7 @@ pycountry==24.6.1
pycsspeechtts==1.0.8
# homeassistant.components.daikin
pydaikin==2.14.1
pydaikin==2.15.0
# homeassistant.components.deako
pydeako==0.6.0
@ -1590,7 +1590,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
pyenphase==1.25.1
pyenphase==1.25.5
# homeassistant.components.everlights
pyeverlights==0.1.0
@ -1889,13 +1889,13 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
pysmartthings==3.0.1
pysmartthings==3.0.2
# homeassistant.components.smarty
pysmarty2==0.10.2
# homeassistant.components.smhi
pysmhi==1.0.0
pysmhi==1.0.1
# homeassistant.components.edl21
pysml==0.0.12
@ -2013,7 +2013,7 @@ python-snoo==0.6.5
python-songpal==0.16.2
# homeassistant.components.tado
python-tado==0.18.9
python-tado==0.18.11
# homeassistant.components.technove
python-technove==2.0.0
@ -2316,7 +2316,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
tesla-fleet-api==1.0.16
tesla-fleet-api==1.0.17
# homeassistant.components.powerwall
tesla-powerwall==0.5.2

View File

@ -59,12 +59,12 @@ def use_mocked_zeroconf(mock_async_zeroconf: MagicMock) -> None:
@pytest.fixture(autouse=True)
def mock_setup_entry() -> Generator[None]:
def mock_setup_entry() -> Generator[Mock]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.apple_tv.async_setup_entry", return_value=True
):
yield
) as setup_entry:
yield setup_entry
# User Flows
@ -1183,7 +1183,9 @@ async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> N
@pytest.mark.usefixtures("mrp_device", "pairing")
async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None:
async def test_reconfigure_update_credentials(
hass: HomeAssistant, mock_setup_entry: Mock
) -> None:
"""Test that reconfigure flow updates config entry."""
config_entry = MockConfigEntry(
domain="apple_tv", unique_id="mrpid", data={"identifiers": ["mrpid"]}
@ -1215,6 +1217,9 @@ async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None:
"identifiers": ["mrpid"],
}
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
# Options

View File

@ -39,9 +39,8 @@ from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID
from tests.common import MockConfigEntry
@pytest.fixture
def mock_models():
"""Mock the model list API."""
def get_models_pager():
"""Return a generator that yields the models."""
model_20_flash = Mock(
display_name="Gemini 2.0 Flash",
supported_actions=["generateContent"],
@ -72,11 +71,7 @@ def mock_models():
yield model_15_pro
yield model_10_pro
with patch(
"google.genai.models.AsyncModels.list",
return_value=models_pager(),
):
yield
return models_pager()
async def test_form(hass: HomeAssistant) -> None:
@ -119,8 +114,13 @@ async def test_form(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
def will_options_be_rendered_again(current_options, new_options) -> bool:
"""Determine if options will be rendered again."""
return current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED)
@pytest.mark.parametrize(
("current_options", "new_options", "expected_options"),
("current_options", "new_options", "expected_options", "errors"),
[
(
{
@ -147,6 +147,7 @@ async def test_form(hass: HomeAssistant) -> None:
CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
},
None,
),
(
{
@ -157,6 +158,7 @@ async def test_form(hass: HomeAssistant) -> None:
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_TOP_K: RECOMMENDED_TOP_K,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_USE_GOOGLE_SEARCH_TOOL: True,
},
{
CONF_RECOMMENDED: True,
@ -168,6 +170,98 @@ async def test_form(hass: HomeAssistant) -> None:
CONF_LLM_HASS_API: "assist",
CONF_PROMPT: "",
},
None,
),
(
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_TEMPERATURE: 0.3,
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_TOP_K: RECOMMENDED_TOP_K,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
},
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_TEMPERATURE: 0.3,
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_TOP_K: RECOMMENDED_TOP_K,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_USE_GOOGLE_SEARCH_TOOL: True,
},
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_TEMPERATURE: 0.3,
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_TOP_K: RECOMMENDED_TOP_K,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_USE_GOOGLE_SEARCH_TOOL: True,
},
None,
),
(
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_TEMPERATURE: 0.3,
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_TOP_K: RECOMMENDED_TOP_K,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_USE_GOOGLE_SEARCH_TOOL: True,
},
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_LLM_HASS_API: "assist",
CONF_TEMPERATURE: 0.3,
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_TOP_K: RECOMMENDED_TOP_K,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_USE_GOOGLE_SEARCH_TOOL: True,
},
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_TEMPERATURE: 0.3,
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_TOP_K: RECOMMENDED_TOP_K,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_USE_GOOGLE_SEARCH_TOOL: True,
},
{CONF_USE_GOOGLE_SEARCH_TOOL: "invalid_google_search_option"},
),
],
)
@ -175,10 +269,10 @@ async def test_form(hass: HomeAssistant) -> None:
async def test_options_switching(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_models,
current_options,
new_options,
expected_options,
errors,
) -> None:
"""Test the options form."""
with patch("google.genai.models.AsyncModels.get"):
@ -186,25 +280,43 @@ async def test_options_switching(
mock_config_entry, options=current_options
)
await hass.async_block_till_done()
with patch(
"google.genai.models.AsyncModels.list",
return_value=get_models_pager(),
):
options_flow = await hass.config_entries.options.async_init(
mock_config_entry.entry_id
)
if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED):
options_flow = await hass.config_entries.options.async_configure(
options_flow["flow_id"],
{
if will_options_be_rendered_again(current_options, new_options):
retry_options = {
**current_options,
CONF_RECOMMENDED: new_options[CONF_RECOMMENDED],
},
}
with patch(
"google.genai.models.AsyncModels.list",
return_value=get_models_pager(),
):
options_flow = await hass.config_entries.options.async_configure(
options_flow["flow_id"],
retry_options,
)
with patch(
"google.genai.models.AsyncModels.list",
return_value=get_models_pager(),
):
options = await hass.config_entries.options.async_configure(
options_flow["flow_id"],
new_options,
)
await hass.async_block_till_done()
if errors is None:
assert options["type"] is FlowResultType.CREATE_ENTRY
assert options["data"] == expected_options
else:
assert options["type"] is FlowResultType.FORM
assert options.get("errors", None) == errors
@pytest.mark.parametrize(
("side_effect", "error"),

View File

@ -54,6 +54,14 @@ from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed
INITIAL_FETCH_CLIENT_METHODS = [
"get_settings",
"get_status",
"get_all_programs",
"get_available_commands",
"get_available_program",
]
@pytest.fixture
def platforms() -> list[str]:
@ -214,15 +222,32 @@ async def test_coordinator_failure_refresh_and_stream(
assert state.state != STATE_UNAVAILABLE
@pytest.mark.parametrize(
"appliance",
["Dishwasher"],
indirect=True,
)
async def test_coordinator_not_fetching_on_disconnected_appliance(
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
appliance: HomeAppliance,
) -> None:
"""Test that the coordinator does not fetch anything on disconnected appliance."""
appliance.connected = False
assert config_entry.state == ConfigEntryState.NOT_LOADED
await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
for method in INITIAL_FETCH_CLIENT_METHODS:
assert getattr(client, method).call_count == 0
@pytest.mark.parametrize(
"mock_method",
[
"get_settings",
"get_status",
"get_all_programs",
"get_available_commands",
"get_available_program",
],
INITIAL_FETCH_CLIENT_METHODS,
)
async def test_coordinator_update_failing(
mock_method: str,
@ -551,3 +576,35 @@ async def test_devices_updated_on_refresh(
assert not device_registry.async_get_device({(DOMAIN, appliances[0].ha_id)})
for appliance in appliances[2:3]:
assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)})
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
async def test_paired_disconnected_devices_not_fetching(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
appliance: HomeAppliance,
) -> None:
"""Test that Home Connect API is not fetched after pairing a disconnected device."""
client.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances([]))
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
appliance.connected = False
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.PAIRED,
data=ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id)
for method in INITIAL_FETCH_CLIENT_METHODS:
assert getattr(client, method).call_count == 0

View File

@ -28,6 +28,10 @@ CONFIG_ENTRY_DATA_2 = {
"firmware": "ezsp",
}
CONFIG_ENTRY_DATA_BAD = {
"device": "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_a87b7d75b18beb119fe564a0f320645d-if00-port0",
}
async def test_hardware_info(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info
@ -59,9 +63,20 @@ async def test_hardware_info(
minor_version=2,
)
config_entry_2.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry_2.entry_id)
config_entry_bad = MockConfigEntry(
data=CONFIG_ENTRY_DATA_BAD,
domain=DOMAIN,
options={},
title="Home Assistant Connect ZBT-1",
unique_id="unique_3",
version=1,
minor_version=2,
)
config_entry_bad.add_to_hass(hass)
assert not await hass.config_entries.async_setup(config_entry_bad.entry_id)
client = await hass_ws_client(hass)
await client.send_json({"id": 1, "type": "hardware/info"})
@ -97,5 +112,6 @@ async def test_hardware_info(
"name": "Home Assistant Connect ZBT-1",
"url": "https://skyconnect.home-assistant.io/documentation/",
},
# Bad entry is skipped
]
}

View File

@ -4508,23 +4508,19 @@ async def test_compile_statistics_hourly_daily_monthly_summary(
duration += dur
return total / duration
def _time_weighted_circular_mean(values: list[tuple[float, int]]):
def _weighted_circular_mean(
values: Iterable[tuple[float, float]],
) -> tuple[float, float]:
sin_sum = 0
cos_sum = 0
for x, dur in values:
sin_sum += math.sin(x * DEG_TO_RAD) * dur
cos_sum += math.cos(x * DEG_TO_RAD) * dur
for x, weight in values:
sin_sum += math.sin(x * DEG_TO_RAD) * weight
cos_sum += math.cos(x * DEG_TO_RAD) * weight
return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360
def _circular_mean(values: list[float]) -> float:
sin_sum = 0
cos_sum = 0
for x in values:
sin_sum += math.sin(x * DEG_TO_RAD)
cos_sum += math.cos(x * DEG_TO_RAD)
return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360
return (
(RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360,
math.sqrt(sin_sum**2 + cos_sum**2),
)
def _min(seq, last_state):
if last_state is None:
@ -4631,7 +4627,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary(
values = [(seq, durations[j]) for j, seq in enumerate(seq)]
if (state := last_states["sensor.test5"]) is not None:
values.append((state, 5))
expected_means["sensor.test5"].append(_time_weighted_circular_mean(values))
expected_means["sensor.test5"].append(_weighted_circular_mean(values))
last_states["sensor.test5"] = seq[-1]
start += timedelta(minutes=5)
@ -4733,15 +4729,17 @@ async def test_compile_statistics_hourly_daily_monthly_summary(
start = zero
end = zero + timedelta(minutes=5)
for i in range(24):
for entity_id in (
"sensor.test1",
"sensor.test2",
"sensor.test3",
"sensor.test4",
"sensor.test5",
for entity_id, mean_extractor in (
("sensor.test1", lambda x: x),
("sensor.test2", lambda x: x),
("sensor.test3", lambda x: x),
("sensor.test4", lambda x: x),
("sensor.test5", lambda x: x[0]),
):
expected_average = (
expected_means[entity_id][i] if entity_id in expected_means else None
mean_extractor(expected_means[entity_id][i])
if entity_id in expected_means
else None
)
expected_minimum = (
expected_minima[entity_id][i] if entity_id in expected_minima else None
@ -4772,7 +4770,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary(
assert stats == expected_stats
def verify_stats(
period: Literal["5minute", "day", "hour", "week", "month"],
period: Literal["hour", "day", "week", "month"],
start: datetime,
next_datetime: Callable[[datetime], datetime],
) -> None:
@ -4791,7 +4789,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary(
("sensor.test2", mean),
("sensor.test3", mean),
("sensor.test4", mean),
("sensor.test5", _circular_mean),
("sensor.test5", lambda x: _weighted_circular_mean(x)[0]),
):
expected_average = (
mean_fn(expected_means[entity_id][i * 12 : (i + 1) * 12])

View File

@ -473,7 +473,7 @@
"timestamp": "2024-09-10T10:26:28.781Z"
},
"acOptionalMode": {
"value": "off",
"value": "windFree",
"timestamp": "2025-02-09T09:14:39.642Z"
}
},

View File

@ -211,7 +211,7 @@
]),
'max_temp': 35,
'min_temp': 7,
'preset_mode': None,
'preset_mode': 'windFree',
'preset_modes': list([
'windFree',
]),

View File

@ -1065,7 +1065,7 @@
'custom.airConditionerOptionalMode': dict({
'acOptionalMode': dict({
'timestamp': '2025-02-09T09:14:39.642Z',
'value': 'off',
'value': 'windFree',
}),
'supportedAcOptionalMode': dict({
'timestamp': '2024-09-10T10:26:28.781Z',

View File

@ -82,6 +82,15 @@ OPTIMISTIC_TEMPLATE_ALARM_CONFIG = {
"data": {"code": "{{ this.entity_id }}"},
},
}
EMPTY_ACTIONS = {
"arm_away": [],
"arm_home": [],
"arm_night": [],
"arm_vacation": [],
"arm_custom_bypass": [],
"disarm": [],
"trigger": [],
}
TEMPLATE_ALARM_CONFIG = {
@ -173,6 +182,12 @@ async def test_setup_config_entry(
"panels": {"test_template_panel": OPTIMISTIC_TEMPLATE_ALARM_CONFIG},
}
},
{
"alarm_control_panel": {
"platform": "template",
"panels": {"test_template_panel": EMPTY_ACTIONS},
}
},
],
)
@pytest.mark.usefixtures("start_ha")

View File

@ -93,6 +93,45 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None:
_verify(hass, STATE_UNKNOWN)
async def test_missing_emtpy_press_action_config(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test: missing optional template is ok."""
with assert_setup_component(1, "template"):
assert await setup.async_setup_component(
hass,
"template",
{
"template": {
"button": {
"press": [],
},
}
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
_verify(hass, STATE_UNKNOWN)
now = dt.datetime.now(dt.UTC)
freezer.move_to(now)
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{CONF_ENTITY_ID: _TEST_BUTTON},
blocking=True,
)
_verify(
hass,
now.isoformat(),
)
async def test_missing_required_keys(hass: HomeAssistant) -> None:
"""Test: missing required fields will fail."""
with assert_setup_component(0, "template"):

View File

@ -9,6 +9,7 @@ from homeassistant.components.cover import (
ATTR_POSITION,
ATTR_TILT_POSITION,
DOMAIN as COVER_DOMAIN,
CoverEntityFeature,
CoverState,
)
from homeassistant.const import (
@ -28,6 +29,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.common import assert_setup_component
@ -1123,3 +1125,50 @@ async def test_self_referencing_icon_with_no_template_is_not_a_loop(
assert len(hass.states.async_all()) == 1
assert "Template loop detected" not in caplog.text
@pytest.mark.parametrize(
("script", "supported_feature"),
[
("stop_cover", CoverEntityFeature.STOP),
("set_cover_position", CoverEntityFeature.SET_POSITION),
(
"set_cover_tilt_position",
CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT
| CoverEntityFeature.SET_TILT_POSITION,
),
],
)
async def test_emtpy_action_config(
hass: HomeAssistant, script: str, supported_feature: CoverEntityFeature
) -> None:
"""Test configuration with empty script."""
with assert_setup_component(1, COVER_DOMAIN):
assert await async_setup_component(
hass,
COVER_DOMAIN,
{
COVER_DOMAIN: {
"platform": "template",
"covers": {
"test_template_cover": {
"open_cover": [],
"close_cover": [],
script: [],
}
},
}
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
state = hass.states.get("cover.test_template_cover")
assert (
state.attributes["supported_features"]
== CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | supported_feature
)

View File

@ -556,6 +556,42 @@ async def setup_single_action_light(
)
@pytest.fixture
async def setup_empty_action_light(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
action: str,
extra_config: dict,
) -> None:
"""Do setup of light integration."""
if style == ConfigurationStyle.LEGACY:
await async_setup_legacy_format(
hass,
count,
{
"test_template_light": {
"turn_on": [],
"turn_off": [],
action: [],
**extra_config,
}
},
)
elif style == ConfigurationStyle.MODERN:
await async_setup_new_format(
hass,
count,
{
"name": "test_template_light",
"turn_on": [],
"turn_off": [],
action: [],
**extra_config,
},
)
@pytest.fixture
async def setup_light_with_effects(
hass: HomeAssistant,
@ -2404,3 +2440,82 @@ async def test_nested_unique_id(
entry = entity_registry.async_get("light.test_b")
assert entry
assert entry.unique_id == "x-b"
@pytest.mark.parametrize(("count", "extra_config"), [(1, {})])
@pytest.mark.parametrize(
"style",
[
ConfigurationStyle.LEGACY,
ConfigurationStyle.MODERN,
],
)
@pytest.mark.parametrize(
("action", "color_mode"),
[
("set_level", ColorMode.BRIGHTNESS),
("set_temperature", ColorMode.COLOR_TEMP),
("set_hs", ColorMode.HS),
("set_rgb", ColorMode.RGB),
("set_rgbw", ColorMode.RGBW),
("set_rgbww", ColorMode.RGBWW),
],
)
async def test_empty_color_mode_action_config(
hass: HomeAssistant,
color_mode: ColorMode,
setup_empty_action_light,
) -> None:
"""Test empty actions for color mode actions."""
state = hass.states.get("light.test_template_light")
assert state.attributes["supported_color_modes"] == [color_mode]
await hass.services.async_call(
light.DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "light.test_template_light"},
blocking=True,
)
state = hass.states.get("light.test_template_light")
assert state.state == STATE_ON
await hass.services.async_call(
light.DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "light.test_template_light"},
blocking=True,
)
state = hass.states.get("light.test_template_light")
assert state.state == STATE_OFF
@pytest.mark.parametrize(("count"), [1])
@pytest.mark.parametrize(
("style", "extra_config"),
[
(
ConfigurationStyle.LEGACY,
{
"effect_list_template": "{{ ['a'] }}",
"effect_template": "{{ 'a' }}",
},
),
(
ConfigurationStyle.MODERN,
{
"effect_list": "{{ ['a'] }}",
"effect": "{{ 'a' }}",
},
),
],
)
@pytest.mark.parametrize("action", ["set_effect"])
async def test_effect_with_empty_action(
hass: HomeAssistant,
setup_empty_action_light,
) -> None:
"""Test empty set_effect action."""
state = hass.states.get("light.test_template_light")
assert state.attributes["supported_features"] == LightEntityFeature.EFFECT

View File

@ -4,7 +4,7 @@ import pytest
from homeassistant import setup
from homeassistant.components import lock
from homeassistant.components.lock import LockState
from homeassistant.components.lock import LockEntityFeature, LockState
from homeassistant.const import (
ATTR_CODE,
ATTR_ENTITY_ID,
@ -15,6 +15,8 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, ServiceCall
from tests.common import assert_setup_component
OPTIMISTIC_LOCK_CONFIG = {
"platform": "template",
"lock": {
@ -718,3 +720,50 @@ async def test_unique_id(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert len(hass.states.async_all("lock")) == 1
async def test_emtpy_action_config(hass: HomeAssistant) -> None:
"""Test configuration with empty script."""
with assert_setup_component(1, lock.DOMAIN):
assert await setup.async_setup_component(
hass,
lock.DOMAIN,
{
lock.DOMAIN: {
"platform": "template",
"value_template": "{{ 0 == 1 }}",
"lock": [],
"unlock": [],
"open": [],
"name": "test_template_lock",
"optimistic": True,
},
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
state = hass.states.get("lock.test_template_lock")
assert state.attributes["supported_features"] == LockEntityFeature.OPEN
await hass.services.async_call(
lock.DOMAIN,
lock.SERVICE_UNLOCK,
{ATTR_ENTITY_ID: "lock.test_template_lock"},
)
await hass.async_block_till_done()
state = hass.states.get("lock.test_template_lock")
assert state.state == LockState.UNLOCKED
await hass.services.async_call(
lock.DOMAIN,
lock.SERVICE_LOCK,
{ATTR_ENTITY_ID: "lock.test_template_lock"},
)
await hass.async_block_till_done()
state = hass.states.get("lock.test_template_lock")
assert state.state == LockState.LOCKED

View File

@ -1,8 +1,12 @@
"""The tests for the Template number platform."""
from typing import Any
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant import setup
from homeassistant.components import number, template
from homeassistant.components.input_number import (
ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE,
DOMAIN as INPUT_NUMBER_DOMAIN,
@ -18,6 +22,7 @@ from homeassistant.components.number import (
)
from homeassistant.components.template import DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ICON,
CONF_ENTITY_ID,
CONF_UNIT_OF_MEASUREMENT,
@ -25,10 +30,14 @@ from homeassistant.const import (
)
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import ConfigurationStyle
from tests.common import MockConfigEntry, assert_setup_component, async_capture_events
_TEST_NUMBER = "number.template_number"
_TEST_OBJECT_ID = "template_number"
_TEST_NUMBER = f"number.{_TEST_OBJECT_ID}"
# Represent for number's value
_VALUE_INPUT_NUMBER = "input_number.value"
# Represent for number's minimum
@ -50,6 +59,38 @@ _VALUE_INPUT_NUMBER_CONFIG = {
}
async def async_setup_modern_format(
hass: HomeAssistant, count: int, number_config: dict[str, Any]
) -> None:
"""Do setup of number integration via new format."""
config = {"template": {"number": number_config}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.fixture
async def setup_number(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
number_config: dict[str, Any],
) -> None:
"""Do setup of number integration."""
if style == ConfigurationStyle.MODERN:
await async_setup_modern_format(
hass, count, {"name": _TEST_OBJECT_ID, **number_config}
)
async def test_setup_config_entry(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
@ -565,3 +606,36 @@ async def test_device_id(
template_entity = entity_registry.async_get("number.my_template")
assert template_entity is not None
assert template_entity.device_id == device_entry.id
@pytest.mark.parametrize(
("count", "number_config"),
[
(
1,
{
"state": "{{ 1 }}",
"set_value": [],
"step": "{{ 1 }}",
"optimistic": True,
},
)
],
)
@pytest.mark.parametrize(
"style",
[
ConfigurationStyle.MODERN,
],
)
async def test_empty_action_config(hass: HomeAssistant, setup_number) -> None:
"""Test configuration with empty script."""
await hass.services.async_call(
number.DOMAIN,
number.SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4},
blocking=True,
)
state = hass.states.get(_TEST_NUMBER)
assert float(state.state) == 4

View File

@ -1,8 +1,12 @@
"""The tests for the Template select platform."""
from typing import Any
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant import setup
from homeassistant.components import select, template
from homeassistant.components.input_select import (
ATTR_OPTION as INPUT_SELECT_ATTR_OPTION,
ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS,
@ -17,17 +21,53 @@ from homeassistant.components.select import (
SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION,
)
from homeassistant.components.template import DOMAIN
from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN
from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import ConfigurationStyle
from tests.common import MockConfigEntry, assert_setup_component, async_capture_events
_TEST_SELECT = "select.template_select"
_TEST_OBJECT_ID = "template_select"
_TEST_SELECT = f"select.{_TEST_OBJECT_ID}"
# Represent for select's current_option
_OPTION_INPUT_SELECT = "input_select.option"
async def async_setup_modern_format(
hass: HomeAssistant, count: int, select_config: dict[str, Any]
) -> None:
"""Do setup of select integration via new format."""
config = {"template": {"select": select_config}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.fixture
async def setup_select(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
select_config: dict[str, Any],
) -> None:
"""Do setup of select integration."""
if style == ConfigurationStyle.MODERN:
await async_setup_modern_format(
hass, count, {"name": _TEST_OBJECT_ID, **select_config}
)
async def test_setup_config_entry(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
@ -527,3 +567,36 @@ async def test_device_id(
template_entity = entity_registry.async_get("select.my_template")
assert template_entity is not None
assert template_entity.device_id == device_entry.id
@pytest.mark.parametrize(
("count", "select_config"),
[
(
1,
{
"state": "{{ 'b' }}",
"select_option": [],
"options": "{{ ['a', 'b'] }}",
"optimistic": True,
},
)
],
)
@pytest.mark.parametrize(
"style",
[
ConfigurationStyle.MODERN,
],
)
async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None:
"""Test configuration with empty script."""
await hass.services.async_call(
select.DOMAIN,
select.SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _TEST_SELECT, "option": "a"},
blocking=True,
)
state = hass.states.get(_TEST_SELECT)
assert state.state == "a"

View File

@ -981,3 +981,49 @@ async def test_device_id(
template_entity = entity_registry.async_get("switch.my_template")
assert template_entity is not None
assert template_entity.device_id == device_entry.id
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
("style", "switch_config"),
[
(
ConfigurationStyle.LEGACY,
{
TEST_OBJECT_ID: {
"turn_on": [],
"turn_off": [],
},
},
),
(
ConfigurationStyle.MODERN,
{
"name": TEST_OBJECT_ID,
"turn_on": [],
"turn_off": [],
},
),
],
)
async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None:
"""Test configuration with empty script."""
await hass.services.async_call(
switch.DOMAIN,
switch.SERVICE_TURN_ON,
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_ON
await hass.services.async_call(
switch.DOMAIN,
switch.SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_OFF

View File

@ -1,18 +1,29 @@
"""The tests for the Template vacuum platform."""
from typing import Any
import pytest
from homeassistant import setup
from homeassistant.components.vacuum import ATTR_BATTERY_LEVEL, VacuumActivity
from homeassistant.components import vacuum
from homeassistant.components.vacuum import (
ATTR_BATTERY_LEVEL,
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.setup import async_setup_component
from .conftest import ConfigurationStyle
from tests.common import assert_setup_component
from tests.components.vacuum import common
_TEST_VACUUM = "vacuum.test_vacuum"
_TEST_OBJECT_ID = "test_vacuum"
_TEST_VACUUM = f"vacuum.{_TEST_OBJECT_ID}"
_STATE_INPUT_SELECT = "input_select.state"
_SPOT_CLEANING_INPUT_BOOLEAN = "input_boolean.spot_cleaning"
_LOCATING_INPUT_BOOLEAN = "input_boolean.locating"
@ -20,6 +31,50 @@ _FAN_SPEED_INPUT_SELECT = "input_select.fan_speed"
_BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level"
async def async_setup_legacy_format(
hass: HomeAssistant, count: int, vacuum_config: dict[str, Any]
) -> None:
"""Do setup of number integration via new format."""
config = {"vacuum": {"platform": "template", "vacuums": vacuum_config}}
with assert_setup_component(count, vacuum.DOMAIN):
assert await async_setup_component(
hass,
vacuum.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.fixture
async def setup_vacuum(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
vacuum_config: dict[str, Any],
) -> None:
"""Do setup of number integration."""
if style == ConfigurationStyle.LEGACY:
await async_setup_legacy_format(hass, count, vacuum_config)
@pytest.fixture
async def setup_test_vacuum_with_extra_config(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
vacuum_config: dict[str, Any],
extra_config: dict[str, Any],
) -> None:
"""Do setup of number integration."""
config = {_TEST_OBJECT_ID: {**vacuum_config, **extra_config}}
if style == ConfigurationStyle.LEGACY:
await async_setup_legacy_format(hass, count, config)
@pytest.mark.parametrize(("count", "domain"), [(1, "vacuum")])
@pytest.mark.parametrize(
("parm1", "parm2", "config"),
@ -697,3 +752,71 @@ async def _register_components(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
("style", "vacuum_config"),
[
(
ConfigurationStyle.LEGACY,
{
"start": [],
},
),
],
)
@pytest.mark.parametrize(
("extra_config", "supported_features"),
[
(
{
"pause": [],
},
VacuumEntityFeature.PAUSE,
),
(
{
"stop": [],
},
VacuumEntityFeature.STOP,
),
(
{
"return_to_base": [],
},
VacuumEntityFeature.RETURN_HOME,
),
(
{
"clean_spot": [],
},
VacuumEntityFeature.CLEAN_SPOT,
),
(
{
"locate": [],
},
VacuumEntityFeature.LOCATE,
),
(
{
"set_fan_speed": [],
},
VacuumEntityFeature.FAN_SPEED,
),
],
)
async def test_empty_action_config(
hass: HomeAssistant,
supported_features: VacuumEntityFeature,
setup_test_vacuum_with_extra_config,
) -> None:
"""Test configuration with empty script."""
await common.async_start(hass, _TEST_VACUUM)
await hass.async_block_till_done()
state = hass.states.get(_TEST_VACUUM)
assert state.attributes["supported_features"] == (
VacuumEntityFeature.STATE | VacuumEntityFeature.START | supported_features
)

View File

@ -1,6 +1,7 @@
"""Test the Tesla Fleet init."""
from copy import deepcopy
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from aiohttp import RequestInfo
@ -231,22 +232,23 @@ async def test_vehicle_sleep(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test coordinator refresh with an error."""
TEST_INTERVAL = timedelta(seconds=120)
with patch(
"homeassistant.components.tesla_fleet.coordinator.VEHICLE_INTERVAL",
TEST_INTERVAL,
):
await setup_platform(hass, normal_config_entry)
assert mock_vehicle_data.call_count == 1
freezer.tick(VEHICLE_WAIT + VEHICLE_INTERVAL)
freezer.tick(VEHICLE_WAIT + TEST_INTERVAL)
async_fire_time_changed(hass)
# Let vehicle sleep, no updates for 15 minutes
await hass.async_block_till_done()
assert mock_vehicle_data.call_count == 2
freezer.tick(VEHICLE_INTERVAL)
async_fire_time_changed(hass)
# No polling, call_count should not increase
await hass.async_block_till_done()
assert mock_vehicle_data.call_count == 2
freezer.tick(VEHICLE_INTERVAL)
freezer.tick(TEST_INTERVAL)
async_fire_time_changed(hass)
# No polling, call_count should not increase
await hass.async_block_till_done()
@ -258,26 +260,26 @@ async def test_vehicle_sleep(
await hass.async_block_till_done()
assert mock_vehicle_data.call_count == 3
freezer.tick(VEHICLE_INTERVAL)
freezer.tick(TEST_INTERVAL)
async_fire_time_changed(hass)
# Regular polling
await hass.async_block_till_done()
assert mock_vehicle_data.call_count == 4
mock_vehicle_data.return_value = VEHICLE_DATA_ALT
freezer.tick(VEHICLE_INTERVAL)
freezer.tick(TEST_INTERVAL)
async_fire_time_changed(hass)
# Vehicle active
await hass.async_block_till_done()
assert mock_vehicle_data.call_count == 5
freezer.tick(VEHICLE_WAIT)
freezer.tick(TEST_INTERVAL)
async_fire_time_changed(hass)
# Dont let sleep when active
await hass.async_block_till_done()
assert mock_vehicle_data.call_count == 6
freezer.tick(VEHICLE_WAIT)
freezer.tick(TEST_INTERVAL)
async_fire_time_changed(hass)
# Dont let sleep when active
await hass.async_block_till_done()

View File

@ -49,7 +49,8 @@ def test_detect_radio_hardware(hass: HomeAssistant) -> None:
"product": "SkyConnect v1.0",
"firmware": "ezsp",
},
version=2,
version=1,
minor_version=4,
domain=SKYCONNECT_DOMAIN,
options={},
title="Home Assistant SkyConnect",
@ -66,7 +67,8 @@ def test_detect_radio_hardware(hass: HomeAssistant) -> None:
"product": "Home Assistant Connect ZBT-1",
"firmware": "ezsp",
},
version=2,
version=1,
minor_version=4,
domain=SKYCONNECT_DOMAIN,
options={},
title="Home Assistant Connect ZBT-1",

View File

@ -8883,15 +8883,17 @@ async def test_add_description_placeholder_automatically_not_overwrites(
@pytest.mark.parametrize(
("domain", "expected_log"),
("domain", "source", "expected_log"),
[
("some_integration", True),
("mobile_app", False),
("some_integration", config_entries.SOURCE_USER, True),
("some_integration", config_entries.SOURCE_IGNORE, False),
("mobile_app", config_entries.SOURCE_USER, False),
],
)
async def test_create_entry_existing_unique_id(
hass: HomeAssistant,
domain: str,
source: str,
expected_log: bool,
caplog: pytest.LogCaptureFixture,
) -> None:
@ -8902,6 +8904,7 @@ async def test_create_entry_existing_unique_id(
entry_id="01J915Q6T9F6G5V0QJX6HBC94T",
data={"host": "any", "port": 123},
unique_id="mock-unique-id",
source=source,
)
entry.add_to_hass(hass)