* 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.components import zeroconf
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_IGNORE, SOURCE_IGNORE,
SOURCE_REAUTH,
SOURCE_ZEROCONF, SOURCE_ZEROCONF,
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_IDENTIFIERS: list(combined_identifiers), 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) self.hass.config_entries.async_schedule_reload(entry.entry_id)
if not allow_exist: if not allow_exist:
raise DeviceAlreadyConfigured raise DeviceAlreadyConfigured

View File

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

View File

@ -52,7 +52,9 @@
"rest": "Rest", "rest": "Rest",
"sabotated": "Sabotated" "sabotated": "Sabotated"
} }
}, }
},
"humidifier": {
"humidifier": { "humidifier": {
"name": "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 import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC 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 .const import KEY_MAC, TIMEOUT
from .coordinator import DaikinConfigEntry, DaikinCoordinator 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), key=entry.data.get(CONF_API_KEY),
uuid=entry.data.get(CONF_UUID), uuid=entry.data.get(CONF_UUID),
password=entry.data.get(CONF_PASSWORD), password=entry.data.get(CONF_PASSWORD),
ssl_context=client_context_no_verify(),
) )
_LOGGER.debug("Connection to %s successful", host) _LOGGER.debug("Connection to %s successful", host)
except TimeoutError as err: except TimeoutError as err:

View File

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

View File

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

View File

@ -6,5 +6,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["evohome", "evohomeasync", "evohomeasync2"], "loggers": ["evohome", "evohomeasync", "evohomeasync2"],
"quality_scale": "legacy", "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 = ( device.ha_id = (
f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_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): if device.enabled and (not device.is_plugin or self._import_plugins):
platform = self._map_device_to_platform(device) platform = self._map_device_to_platform(device)
if platform is None: if platform is None:

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar", "documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "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", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "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: ) -> ConfigFlowResult:
"""Manage the options.""" """Manage the options."""
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.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 is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none": if user_input[CONF_LLM_HASS_API] == "none":
user_input.pop(CONF_LLM_HASS_API) user_input.pop(CONF_LLM_HASS_API)
return self.async_create_entry(title="", data=user_input) 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 # Re-render the options again, now with the recommended options shown/hidden
self.last_rendered_recommended = user_input[CONF_RECOMMENDED] self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
options = { options = user_input
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT],
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
}
schema = await google_generative_ai_config_option_schema( schema = await google_generative_ai_config_option_schema(
self.hass, options, self._genai_client self.hass, options, self._genai_client
) )
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init", data_schema=vol.Schema(schema), errors=errors
data_schema=vol.Schema(schema),
) )

View File

@ -43,6 +43,9 @@
"prompt": "Instruct how the LLM should respond. This can be a template." "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": { "services": {

View File

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

View File

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

View File

@ -73,6 +73,19 @@ class HomeConnectApplianceData:
self.settings.update(other.settings) self.settings.update(other.settings)
self.status.update(other.status) 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( class HomeConnectCoordinator(
DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] DataUpdateCoordinator[dict[str, HomeConnectApplianceData]]
@ -358,15 +371,7 @@ class HomeConnectCoordinator(
model=appliance.vib, model=appliance.vib,
) )
if appliance.ha_id not in self.data: if appliance.ha_id not in self.data:
self.data[appliance.ha_id] = HomeConnectApplianceData( self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance)
commands=set(),
events={},
info=appliance,
options={},
programs=[],
settings={},
status={},
)
else: else:
self.data[appliance.ha_id].info.connected = appliance.connected self.data[appliance.ha_id].info.connected = appliance.connected
old_appliances.remove(appliance.ha_id) old_appliances.remove(appliance.ha_id)
@ -402,6 +407,15 @@ class HomeConnectCoordinator(
name=appliance.name, name=appliance.name,
model=appliance.vib, 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: try:
settings = { settings = {
setting.key: setting setting.key: setting

View File

@ -5,17 +5,21 @@ from __future__ import annotations
from homeassistant.components.hardware.models import HardwareInfo, USBInfo from homeassistant.components.hardware.models import HardwareInfo, USBInfo
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from .config_flow import HomeAssistantSkyConnectConfigFlow
from .const import DOMAIN from .const import DOMAIN
from .util import get_hardware_variant from .util import get_hardware_variant
DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/" DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/"
EXPECTED_ENTRY_VERSION = (
HomeAssistantSkyConnectConfigFlow.VERSION,
HomeAssistantSkyConnectConfigFlow.MINOR_VERSION,
)
@callback @callback
def async_info(hass: HomeAssistant) -> list[HardwareInfo]: def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
"""Return board info.""" """Return board info."""
entries = hass.config_entries.async_entries(DOMAIN) entries = hass.config_entries.async_entries(DOMAIN)
return [ return [
HardwareInfo( HardwareInfo(
board=None, board=None,
@ -31,4 +35,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
url=DOCUMENTATION_URL, url=DOCUMENTATION_URL,
) )
for entry in entries 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": { "data_description": {
"host": "Hostname or IP-address of the Intergas gateway.", "host": "Hostname or IP-address of the Intergas gateway.",
"username": "The username to log into the gateway. This is `admin` in most cases.", "username": "The username to log in to 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." "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": { "dhcp_auth": {
@ -22,8 +22,8 @@
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
}, },
"data_description": { "data_description": {
"username": "The username to log into the gateway. This is `admin` in most cases.", "username": "[%key:component::incomfort::config::step::user::data_description::username%]",
"password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices." "password": "[%key:component::incomfort::config::step::user::data_description::password%]"
} }
}, },
"dhcp_confirm": { "dhcp_confirm": {

View File

@ -151,6 +151,8 @@ async def async_setup_entry(
assert event.object_id is not None assert event.object_id is not None
if event.object_id in added_ids: if event.object_id in added_ids:
return return
if not player.expose_to_ha:
return
added_ids.add(event.object_id) added_ids.add(event.object_id)
async_add_entities([MusicAssistantPlayer(mass, event.object_id)]) async_add_entities([MusicAssistantPlayer(mass, event.object_id)])
@ -159,6 +161,8 @@ async def async_setup_entry(
mass_players = [] mass_players = []
# add all current players # add all current players
for player in mass.players: for player in mass.players:
if not player.expose_to_ha:
continue
added_ids.add(player.player_id) added_ids.add(player.player_id)
mass_players.append(MusicAssistantPlayer(mass, 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. # in Python.
# https://en.wikipedia.org/wiki/Circular_mean # https://en.wikipedia.org/wiki/Circular_mean
radians = func.radians(table.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( weight = func.sqrt(
func.power(func.sum(func.sin(radians) * table.mean_weight), 2) func.power(weighted_sum_sin, 2) + func.power(weighted_sum_cos, 2)
+ func.power(func.sum(func.cos(radians) * table.mean_weight), 2)
) )
return ( return (
func.degrees( func.degrees(func.atan2(weighted_sum_sin, weighted_sum_cos)).label("mean"),
func.atan2(func.sum(func.sin(radians)), func.sum(func.cos(radians)))
).label("mean"),
weight.label("mean_weight"), weight.label("mean_weight"),
) )
@ -240,18 +239,20 @@ DEG_TO_RAD = math.pi / 180
RAD_TO_DEG = 180 / math.pi RAD_TO_DEG = 180 / math.pi
def weighted_circular_mean(values: Iterable[tuple[float, float]]) -> float: def weighted_circular_mean(
"""Return the weighted circular mean of the values.""" values: Iterable[tuple[float, float]],
sin_sum = sum(math.sin(x * DEG_TO_RAD) * weight for x, weight in values) ) -> tuple[float, float]:
cos_sum = sum(math.cos(x * DEG_TO_RAD) * weight for x, weight in values) """Return the weighted circular mean and the weight of the values."""
return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 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
return (
def circular_mean(values: list[float]) -> float: (RAD_TO_DEG * math.atan2(weighted_sin_sum, weighted_cos_sum)) % 360,
"""Return the circular mean of the values.""" math.sqrt(weighted_sin_sum**2 + weighted_cos_sum**2),
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
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -300,6 +301,7 @@ class StatisticsRow(BaseStatisticsRow, total=False):
min: float | None min: float | None
max: float | None max: float | None
mean: float | None mean: float | None
mean_weight: float | None
change: float | None change: float | None
@ -1023,7 +1025,7 @@ def _reduce_statistics(
_want_sum = "sum" in types _want_sum = "sum" in types
for statistic_id, stat_list in stats.items(): for statistic_id, stat_list in stats.items():
max_values: list[float] = [] max_values: list[float] = []
mean_values: list[float] = [] mean_values: list[tuple[float, float]] = []
min_values: list[float] = [] min_values: list[float] = []
prev_stat: StatisticsRow = stat_list[0] prev_stat: StatisticsRow = stat_list[0]
fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds} fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds}
@ -1039,12 +1041,15 @@ def _reduce_statistics(
} }
if _want_mean: if _want_mean:
row["mean"] = None row["mean"] = None
row["mean_weight"] = None
if mean_values: if mean_values:
match metadata[statistic_id][1]["mean_type"]: match metadata[statistic_id][1]["mean_type"]:
case StatisticMeanType.ARITHMETIC: case StatisticMeanType.ARITHMETIC:
row["mean"] = mean(mean_values) row["mean"] = mean([x[0] for x in mean_values])
case StatisticMeanType.CIRCULAR: case StatisticMeanType.CIRCULAR:
row["mean"] = circular_mean(mean_values) row["mean"], row["mean_weight"] = (
weighted_circular_mean(mean_values)
)
mean_values.clear() mean_values.clear()
if _want_min: if _want_min:
row["min"] = min(min_values) if min_values else None row["min"] = min(min_values) if min_values else None
@ -1063,7 +1068,8 @@ def _reduce_statistics(
max_values.append(_max) max_values.append(_max)
if _want_mean: if _want_mean:
if (_mean := statistic.get("mean")) is not None: 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: if _want_min and (_min := statistic.get("min")) is not None:
min_values.append(_min) min_values.append(_min)
prev_stat = statistic prev_stat = statistic
@ -1385,7 +1391,7 @@ def _get_max_mean_min_statistic(
match metadata[1]["mean_type"]: match metadata[1]["mean_type"]:
case StatisticMeanType.CIRCULAR: case StatisticMeanType.CIRCULAR:
if circular_means := max_mean_min["circular_means"]: 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: case StatisticMeanType.ARITHMETIC:
if (mean_value := max_mean_min.get("mean_acc")) is not None and ( if (mean_value := max_mean_min.get("mean_acc")) is not None and (
duration := max_mean_min.get("duration") duration := max_mean_min.get("duration")
@ -1739,12 +1745,12 @@ def statistic_during_period(
_type_column_mapping = { _type_column_mapping = {
"last_reset": "last_reset_ts", "last_reset": ("last_reset_ts",),
"max": "max", "max": ("max",),
"mean": "mean", "mean": ("mean", "mean_weight"),
"min": "min", "min": ("min",),
"state": "state", "state": ("state",),
"sum": "sum", "sum": ("sum",),
} }
@ -1756,12 +1762,13 @@ def _generate_select_columns_for_types_stmt(
track_on: list[str | None] = [ track_on: list[str | None] = [
table.__tablename__, # type: ignore[attr-defined] table.__tablename__, # type: ignore[attr-defined]
] ]
for key, column in _type_column_mapping.items(): for key, type_columns in _type_column_mapping.items():
if key in types: for column in type_columns:
columns = columns.add_columns(getattr(table, column)) if key in types:
track_on.append(column) columns = columns.add_columns(getattr(table, column))
else: track_on.append(column)
track_on.append(None) else:
track_on.append(None)
return lambda_stmt(lambda: columns, track_on=track_on) return lambda_stmt(lambda: columns, track_on=track_on)
@ -1944,6 +1951,12 @@ def _statistics_during_period_with_session(
hass, session, start_time, units, _types, table, metadata, result 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 statistics combined with metadata
return result return result
@ -2391,7 +2404,12 @@ def _sorted_statistics_to_dict(
field_map["last_reset"] = field_map.pop("last_reset_ts") field_map["last_reset"] = field_map.pop("last_reset_ts")
sum_idx = field_map["sum"] if "sum" in types else None sum_idx = field_map["sum"] if "sum" in types else None
sum_only = len(types) == 1 and sum_idx is not 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 # Append all statistic entries, and optionally do unit conversion
table_duration_seconds = table.duration.total_seconds() table_duration_seconds = table.duration.total_seconds()
for meta_id, db_rows in stats_by_meta_id.items(): 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( def _time_weighted_circular_mean(
fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime
) -> float: ) -> tuple[float, float]:
"""Calculate a time weighted circular mean. """Calculate a time weighted circular mean.
The circular mean is calculated by weighting the states by duration in seconds between 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 valid_float_states, start, end
) )
case StatisticMeanType.CIRCULAR: case StatisticMeanType.CIRCULAR:
stat["mean"] = _time_weighted_circular_mean( stat["mean"], stat["mean_weight"] = _time_weighted_circular_mean(
valid_float_states, start, end valid_float_states, start, end
) )

View File

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

View File

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

View File

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

View File

@ -14,5 +14,5 @@
}, },
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["PyTado"], "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), (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.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature self._attr_supported_features |= supported_feature

View File

@ -120,7 +120,8 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity):
"""Initialize the button.""" """Initialize the button."""
super().__init__(hass, config=config, unique_id=unique_id) super().__init__(hass, config=config, unique_id=unique_id)
assert self._attr_name is not None 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.add_script(CONF_PRESS, action, self._attr_name, DOMAIN)
self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_state = None self._attr_state = None

View File

@ -172,7 +172,8 @@ class CoverTemplate(TemplateEntity, CoverEntity):
(POSITION_ACTION, CoverEntityFeature.SET_POSITION), (POSITION_ACTION, CoverEntityFeature.SET_POSITION),
(TILT_ACTION, TILT_FEATURES), (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.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature self._attr_supported_features |= supported_feature

View File

@ -157,7 +157,8 @@ class TemplateFan(TemplateEntity, FanEntity):
CONF_SET_OSCILLATING_ACTION, CONF_SET_OSCILLATING_ACTION,
CONF_SET_DIRECTION_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.add_script(action_id, action_config, name, DOMAIN)
self._state: bool | None = False self._state: bool | None = False

View File

@ -296,7 +296,8 @@ class LightTemplate(TemplateEntity, LightEntity):
self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION)
for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION): 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.add_script(action_id, action_config, name, DOMAIN)
self._state = False self._state = False
@ -323,7 +324,8 @@ class LightTemplate(TemplateEntity, LightEntity):
(CONF_RGBW_ACTION, ColorMode.RGBW), (CONF_RGBW_ACTION, ColorMode.RGBW),
(CONF_RGBWW_ACTION, ColorMode.RGBWW), (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) self.add_script(action_id, action_config, name, DOMAIN)
color_modes.add(color_mode) color_modes.add(color_mode)
self._supported_color_modes = filter_supported_color_modes(color_modes) 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._color_mode = next(iter(self._supported_color_modes))
self._attr_supported_features = LightEntityFeature(0) 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 self._attr_supported_features |= LightEntityFeature.EFFECT
if self._supports_transition is True: if self._supports_transition is True:
self._attr_supported_features |= LightEntityFeature.TRANSITION self._attr_supported_features |= LightEntityFeature.TRANSITION

View File

@ -98,7 +98,8 @@ class TemplateLock(TemplateEntity, LockEntity):
(CONF_UNLOCK, 0), (CONF_UNLOCK, 0),
(CONF_OPEN, LockEntityFeature.OPEN), (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.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature self._attr_supported_features |= supported_feature
self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE) 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) super().__init__(hass, config=config, unique_id=unique_id)
assert self._attr_name is not None assert self._attr_name is not None
self._value_template = config[CONF_STATE] 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.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN)
self._options_template = config[ATTR_OPTIONS] self._options_template = config[ATTR_OPTIONS]
self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False)
@ -197,7 +198,8 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity):
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(hass, coordinator, config) 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( self.add_script(
CONF_SELECT_OPTION, CONF_SELECT_OPTION,
select_option, select_option,

View File

@ -226,9 +226,10 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
assert name is not None assert name is not None
self._template = config.get(CONF_STATE) 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) 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.add_script(CONF_TURN_OFF, off_action, name, DOMAIN)
self._state: bool | None = False self._state: bool | None = False

View File

@ -158,7 +158,8 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity):
(SERVICE_LOCATE, VacuumEntityFeature.LOCATE), (SERVICE_LOCATE, VacuumEntityFeature.LOCATE),
(SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), (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.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature self._attr_supported_features |= supported_feature

View File

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

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"], "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", "documentation": "https://www.home-assistant.io/integrations/teslemetry",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"], "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", "documentation": "https://www.home-assistant.io/integrations/tessie",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"], "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 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 # This causes the old entry to be removed and replaced, when the flow
# should instead be aborted. # should instead be aborted.
# In case of manual flows, integrations should implement options, reauth, # In case of manual flows, integrations should implement options, reauth,

View File

@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025 MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 4 MINOR_VERSION: Final = 4
PATCH_VERSION: Final = "0" PATCH_VERSION: Final = "1"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) 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 hass-nabucasa==0.94.0
hassil==2.2.3 hassil==2.2.3
home-assistant-bluetooth==1.13.1 home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250401.0 home-assistant-frontend==20250404.0
home-assistant-intents==2025.3.28 home-assistant-intents==2025.3.28
httpx==0.28.1 httpx==0.28.1
ifaddr==0.2.0 ifaddr==0.2.0

View File

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

18
requirements_all.txt generated
View File

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

View File

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

View File

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

View File

@ -39,9 +39,8 @@ from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.fixture def get_models_pager():
def mock_models(): """Return a generator that yields the models."""
"""Mock the model list API."""
model_20_flash = Mock( model_20_flash = Mock(
display_name="Gemini 2.0 Flash", display_name="Gemini 2.0 Flash",
supported_actions=["generateContent"], supported_actions=["generateContent"],
@ -72,11 +71,7 @@ def mock_models():
yield model_15_pro yield model_15_pro
yield model_10_pro yield model_10_pro
with patch( return models_pager()
"google.genai.models.AsyncModels.list",
return_value=models_pager(),
):
yield
async def test_form(hass: HomeAssistant) -> None: 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 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( @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_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD,
CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, 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_P: RECOMMENDED_TOP_P,
CONF_TOP_K: RECOMMENDED_TOP_K, CONF_TOP_K: RECOMMENDED_TOP_K,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_USE_GOOGLE_SEARCH_TOOL: True,
}, },
{ {
CONF_RECOMMENDED: True, CONF_RECOMMENDED: True,
@ -168,6 +170,98 @@ async def test_form(hass: HomeAssistant) -> None:
CONF_LLM_HASS_API: "assist", CONF_LLM_HASS_API: "assist",
CONF_PROMPT: "", 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( async def test_options_switching(
hass: HomeAssistant, hass: HomeAssistant,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
mock_models,
current_options, current_options,
new_options, new_options,
expected_options, expected_options,
errors,
) -> None: ) -> None:
"""Test the options form.""" """Test the options form."""
with patch("google.genai.models.AsyncModels.get"): with patch("google.genai.models.AsyncModels.get"):
@ -186,24 +280,42 @@ async def test_options_switching(
mock_config_entry, options=current_options mock_config_entry, options=current_options
) )
await hass.async_block_till_done() await hass.async_block_till_done()
options_flow = await hass.config_entries.options.async_init( with patch(
mock_config_entry.entry_id "google.genai.models.AsyncModels.list",
) return_value=get_models_pager(),
if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): ):
options_flow = await hass.config_entries.options.async_configure( options_flow = await hass.config_entries.options.async_init(
options_flow["flow_id"], mock_config_entry.entry_id
{ )
**current_options, if will_options_be_rendered_again(current_options, new_options):
CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], 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,
) )
options = await hass.config_entries.options.async_configure(
options_flow["flow_id"],
new_options,
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert options["type"] is FlowResultType.CREATE_ENTRY if errors is None:
assert options["data"] == expected_options 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( @pytest.mark.parametrize(

View File

@ -54,6 +54,14 @@ from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed 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 @pytest.fixture
def platforms() -> list[str]: def platforms() -> list[str]:
@ -214,15 +222,32 @@ async def test_coordinator_failure_refresh_and_stream(
assert state.state != STATE_UNAVAILABLE 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( @pytest.mark.parametrize(
"mock_method", "mock_method",
[ INITIAL_FETCH_CLIENT_METHODS,
"get_settings",
"get_status",
"get_all_programs",
"get_available_commands",
"get_available_program",
],
) )
async def test_coordinator_update_failing( async def test_coordinator_update_failing(
mock_method: str, 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)}) assert not device_registry.async_get_device({(DOMAIN, appliances[0].ha_id)})
for appliance in appliances[2:3]: for appliance in appliances[2:3]:
assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) 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", "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( async def test_hardware_info(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info
@ -59,9 +63,20 @@ async def test_hardware_info(
minor_version=2, minor_version=2,
) )
config_entry_2.add_to_hass(hass) config_entry_2.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry_2.entry_id) 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) client = await hass_ws_client(hass)
await client.send_json({"id": 1, "type": "hardware/info"}) await client.send_json({"id": 1, "type": "hardware/info"})
@ -97,5 +112,6 @@ async def test_hardware_info(
"name": "Home Assistant Connect ZBT-1", "name": "Home Assistant Connect ZBT-1",
"url": "https://skyconnect.home-assistant.io/documentation/", "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 duration += dur
return total / duration 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 sin_sum = 0
cos_sum = 0 cos_sum = 0
for x, dur in values: for x, weight in values:
sin_sum += math.sin(x * DEG_TO_RAD) * dur sin_sum += math.sin(x * DEG_TO_RAD) * weight
cos_sum += math.cos(x * DEG_TO_RAD) * dur cos_sum += math.cos(x * DEG_TO_RAD) * weight
return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 return (
(RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360,
def _circular_mean(values: list[float]) -> float: math.sqrt(sin_sum**2 + cos_sum**2),
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
def _min(seq, last_state): def _min(seq, last_state):
if last_state is None: 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)] values = [(seq, durations[j]) for j, seq in enumerate(seq)]
if (state := last_states["sensor.test5"]) is not None: if (state := last_states["sensor.test5"]) is not None:
values.append((state, 5)) 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] last_states["sensor.test5"] = seq[-1]
start += timedelta(minutes=5) start += timedelta(minutes=5)
@ -4733,15 +4729,17 @@ async def test_compile_statistics_hourly_daily_monthly_summary(
start = zero start = zero
end = zero + timedelta(minutes=5) end = zero + timedelta(minutes=5)
for i in range(24): for i in range(24):
for entity_id in ( for entity_id, mean_extractor in (
"sensor.test1", ("sensor.test1", lambda x: x),
"sensor.test2", ("sensor.test2", lambda x: x),
"sensor.test3", ("sensor.test3", lambda x: x),
"sensor.test4", ("sensor.test4", lambda x: x),
"sensor.test5", ("sensor.test5", lambda x: x[0]),
): ):
expected_average = ( 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_minimum = (
expected_minima[entity_id][i] if entity_id in expected_minima else None 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 assert stats == expected_stats
def verify_stats( def verify_stats(
period: Literal["5minute", "day", "hour", "week", "month"], period: Literal["hour", "day", "week", "month"],
start: datetime, start: datetime,
next_datetime: Callable[[datetime], datetime], next_datetime: Callable[[datetime], datetime],
) -> None: ) -> None:
@ -4791,7 +4789,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary(
("sensor.test2", mean), ("sensor.test2", mean),
("sensor.test3", mean), ("sensor.test3", mean),
("sensor.test4", mean), ("sensor.test4", mean),
("sensor.test5", _circular_mean), ("sensor.test5", lambda x: _weighted_circular_mean(x)[0]),
): ):
expected_average = ( expected_average = (
mean_fn(expected_means[entity_id][i * 12 : (i + 1) * 12]) mean_fn(expected_means[entity_id][i * 12 : (i + 1) * 12])

View File

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

View File

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

View File

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

View File

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

View File

@ -93,6 +93,45 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None:
_verify(hass, STATE_UNKNOWN) _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: async def test_missing_required_keys(hass: HomeAssistant) -> None:
"""Test: missing required fields will fail.""" """Test: missing required fields will fail."""
with assert_setup_component(0, "template"): with assert_setup_component(0, "template"):

View File

@ -9,6 +9,7 @@ from homeassistant.components.cover import (
ATTR_POSITION, ATTR_POSITION,
ATTR_TILT_POSITION, ATTR_TILT_POSITION,
DOMAIN as COVER_DOMAIN, DOMAIN as COVER_DOMAIN,
CoverEntityFeature,
CoverState, CoverState,
) )
from homeassistant.const import ( from homeassistant.const import (
@ -28,6 +29,7 @@ from homeassistant.const import (
STATE_UNKNOWN, STATE_UNKNOWN,
) )
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.common import assert_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 len(hass.states.async_all()) == 1
assert "Template loop detected" not in caplog.text 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 @pytest.fixture
async def setup_light_with_effects( async def setup_light_with_effects(
hass: HomeAssistant, hass: HomeAssistant,
@ -2404,3 +2440,82 @@ async def test_nested_unique_id(
entry = entity_registry.async_get("light.test_b") entry = entity_registry.async_get("light.test_b")
assert entry assert entry
assert entry.unique_id == "x-b" 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 import setup
from homeassistant.components import lock from homeassistant.components import lock
from homeassistant.components.lock import LockState from homeassistant.components.lock import LockEntityFeature, LockState
from homeassistant.const import ( from homeassistant.const import (
ATTR_CODE, ATTR_CODE,
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
@ -15,6 +15,8 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from tests.common import assert_setup_component
OPTIMISTIC_LOCK_CONFIG = { OPTIMISTIC_LOCK_CONFIG = {
"platform": "template", "platform": "template",
"lock": { "lock": {
@ -718,3 +720,50 @@ async def test_unique_id(hass: HomeAssistant) -> None:
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(hass.states.async_all("lock")) == 1 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.""" """The tests for the Template number platform."""
from typing import Any
import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant import setup from homeassistant import setup
from homeassistant.components import number, template
from homeassistant.components.input_number import ( from homeassistant.components.input_number import (
ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE,
DOMAIN as INPUT_NUMBER_DOMAIN, DOMAIN as INPUT_NUMBER_DOMAIN,
@ -18,6 +22,7 @@ from homeassistant.components.number import (
) )
from homeassistant.components.template import DOMAIN from homeassistant.components.template import DOMAIN
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ICON, ATTR_ICON,
CONF_ENTITY_ID, CONF_ENTITY_ID,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
@ -25,10 +30,14 @@ from homeassistant.const import (
) )
from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import ConfigurationStyle
from tests.common import MockConfigEntry, assert_setup_component, async_capture_events 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 # Represent for number's value
_VALUE_INPUT_NUMBER = "input_number.value" _VALUE_INPUT_NUMBER = "input_number.value"
# Represent for number's minimum # 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( async def test_setup_config_entry(
hass: HomeAssistant, hass: HomeAssistant,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
@ -565,3 +606,36 @@ async def test_device_id(
template_entity = entity_registry.async_get("number.my_template") template_entity = entity_registry.async_get("number.my_template")
assert template_entity is not None assert template_entity is not None
assert template_entity.device_id == device_entry.id 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.""" """The tests for the Template select platform."""
from typing import Any
import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant import setup from homeassistant import setup
from homeassistant.components import select, template
from homeassistant.components.input_select import ( from homeassistant.components.input_select import (
ATTR_OPTION as INPUT_SELECT_ATTR_OPTION, ATTR_OPTION as INPUT_SELECT_ATTR_OPTION,
ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS, ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS,
@ -17,17 +21,53 @@ from homeassistant.components.select import (
SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION,
) )
from homeassistant.components.template import DOMAIN 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.core import Context, HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import ConfigurationStyle
from tests.common import MockConfigEntry, assert_setup_component, async_capture_events 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 # Represent for select's current_option
_OPTION_INPUT_SELECT = "input_select.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( async def test_setup_config_entry(
hass: HomeAssistant, hass: HomeAssistant,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
@ -527,3 +567,36 @@ async def test_device_id(
template_entity = entity_registry.async_get("select.my_template") template_entity = entity_registry.async_get("select.my_template")
assert template_entity is not None assert template_entity is not None
assert template_entity.device_id == device_entry.id 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") template_entity = entity_registry.async_get("switch.my_template")
assert template_entity is not None assert template_entity is not None
assert template_entity.device_id == device_entry.id 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.""" """The tests for the Template vacuum platform."""
from typing import Any
import pytest import pytest
from homeassistant import setup 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.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_component import async_update_entity 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.common import assert_setup_component
from tests.components.vacuum import common 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" _STATE_INPUT_SELECT = "input_select.state"
_SPOT_CLEANING_INPUT_BOOLEAN = "input_boolean.spot_cleaning" _SPOT_CLEANING_INPUT_BOOLEAN = "input_boolean.spot_cleaning"
_LOCATING_INPUT_BOOLEAN = "input_boolean.locating" _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" _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(("count", "domain"), [(1, "vacuum")])
@pytest.mark.parametrize( @pytest.mark.parametrize(
("parm1", "parm2", "config"), ("parm1", "parm2", "config"),
@ -697,3 +752,71 @@ async def _register_components(hass: HomeAssistant) -> None:
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.async_start() await hass.async_start()
await hass.async_block_till_done() 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.""" """Test the Tesla Fleet init."""
from copy import deepcopy from copy import deepcopy
from datetime import timedelta
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from aiohttp import RequestInfo from aiohttp import RequestInfo
@ -231,57 +232,58 @@ async def test_vehicle_sleep(
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test coordinator refresh with an error.""" """Test coordinator refresh with an error."""
await setup_platform(hass, normal_config_entry)
assert mock_vehicle_data.call_count == 1
freezer.tick(VEHICLE_WAIT + VEHICLE_INTERVAL) TEST_INTERVAL = timedelta(seconds=120)
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) with patch(
async_fire_time_changed(hass) "homeassistant.components.tesla_fleet.coordinator.VEHICLE_INTERVAL",
# No polling, call_count should not increase TEST_INTERVAL,
await hass.async_block_till_done() ):
assert mock_vehicle_data.call_count == 2 await setup_platform(hass, normal_config_entry)
assert mock_vehicle_data.call_count == 1
freezer.tick(VEHICLE_INTERVAL) freezer.tick(VEHICLE_WAIT + TEST_INTERVAL)
async_fire_time_changed(hass) async_fire_time_changed(hass)
# No polling, call_count should not increase # Let vehicle sleep, no updates for 15 minutes
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_vehicle_data.call_count == 2 assert mock_vehicle_data.call_count == 2
freezer.tick(VEHICLE_WAIT) freezer.tick(TEST_INTERVAL)
async_fire_time_changed(hass) async_fire_time_changed(hass)
# Vehicle didn't sleep, go back to normal # No polling, call_count should not increase
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_vehicle_data.call_count == 3 assert mock_vehicle_data.call_count == 2
freezer.tick(VEHICLE_INTERVAL) freezer.tick(VEHICLE_WAIT)
async_fire_time_changed(hass) async_fire_time_changed(hass)
# Regular polling # Vehicle didn't sleep, go back to normal
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_vehicle_data.call_count == 4 assert mock_vehicle_data.call_count == 3
mock_vehicle_data.return_value = VEHICLE_DATA_ALT freezer.tick(TEST_INTERVAL)
freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass)
async_fire_time_changed(hass) # Regular polling
# Vehicle active await hass.async_block_till_done()
await hass.async_block_till_done() assert mock_vehicle_data.call_count == 4
assert mock_vehicle_data.call_count == 5
freezer.tick(VEHICLE_WAIT) mock_vehicle_data.return_value = VEHICLE_DATA_ALT
async_fire_time_changed(hass) freezer.tick(TEST_INTERVAL)
# Dont let sleep when active async_fire_time_changed(hass)
await hass.async_block_till_done() # Vehicle active
assert mock_vehicle_data.call_count == 6 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) async_fire_time_changed(hass)
# Dont let sleep when active # Dont let sleep when active
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_vehicle_data.call_count == 7 assert mock_vehicle_data.call_count == 6
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 == 7
# Test Energy Live Coordinator # Test Energy Live Coordinator

View File

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

View File

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