Compare commits

..

31 Commits

Author SHA1 Message Date
J. Nick Koston
33868c6fc1 Merge branch 'config_flow_changes_breakout' into improv_name_late 2025-10-12 15:00:52 -10:00
J. Nick Koston
4f15902534 cast 2025-10-12 15:00:25 -10:00
J. Nick Koston
0c5fd9105a Add config flow title placeholder update infrastructure 2025-10-12 14:54:51 -10:00
J. Nick Koston
26e68955a2 cleanup 2025-10-12 14:44:55 -10:00
J. Nick Koston
58b01bb671 tweak 2025-10-12 14:37:43 -10:00
J. Nick Koston
ea3c614d37 async_update_title_placeholders 2025-10-12 14:30:23 -10:00
David Recordon
7739cdc626 Update pyControl4 to v1.5.0 (#154341) 2025-10-12 23:28:08 +02:00
Michael Davie
4ca1ae61aa Environment Canada station selector (#154307)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-12 22:34:16 +02:00
Dave T
3d130a9bdf Simplify generic camera tests (#154313) 2025-10-12 22:06:13 +02:00
Shay Levy
2b38f33d50 Bump aioshelly to 13.13.0 (#154337) 2025-10-12 23:01:22 +03:00
Glenn Vandeuren (aka Iondependent)
19dedb038e Update nhc requirement to version 0.7.0 (#154250) 2025-10-12 21:58:01 +02:00
Dan Schafer
59781422f7 Update Snoo strings.json to include weaning_baseline (#154268) 2025-10-12 21:57:47 +02:00
Thomas55555
083277d1ff Add model_id to Husqvarna Automower (#154335) 2025-10-12 21:45:01 +02:00
Marcus Gustavsson
9b9c55b37b Updated prowlpy to 1.1.1 and changed the usage to do asynchronous calls (#154193) 2025-10-12 21:17:43 +02:00
J. Nick Koston
c9d67d596b Fix August integration to handle unavailable OAuth implementation at startup (#154244) 2025-10-12 09:16:22 -10:00
J. Nick Koston
7948b35265 Fix Yale integration to handle unavailable OAuth implementation at startup (#154245) 2025-10-12 09:16:02 -10:00
Ernst Klamer
be843970fd bump tilt-ble to 1.0.1 (#154320) 2025-10-12 21:38:27 +03:00
Michael Davie
53b65b2fb4 Bump env-canada to v0.12.1 (#154303)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 20:31:02 +02:00
Simone Chemelli
ac7be97245 Bump aioamazondevices to 6.4.3 (#154293) 2025-10-12 19:25:53 +02:00
Jan Bouwhuis
09e539bf0e Fix home wiziard total increasing sensors returning 0 (#154264) 2025-10-12 12:51:50 -04:00
J. Nick Koston
6ef1b3bad3 Bump aioesphomeapi to 41.14.0 (#154275) 2025-10-12 12:51:05 -04:00
Bouwe Westerdijk
38e46f7a53 Bump plugwise to v1.8.0 - add initial support for Emma (#154277) 2025-10-12 12:50:46 -04:00
Michael Davie
ef60d16659 Fix Environment Canada camera entity initialization (#154302)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 12:47:22 -04:00
Marc Mueller
bf4f8b48a3 Update pylint to 4.0.0 + astroid to 4.0.1 (#154311) 2025-10-12 12:46:04 -04:00
Denis Shulyaka
3c1496d2bb Add gpt-image-1-mini support (#154316) 2025-10-12 12:44:38 -04:00
Mick Vleeshouwer
d457787639 Move URL out of Overkiz Config Flow descriptions (#154315) 2025-10-12 18:23:24 +02:00
Mick Vleeshouwer
de4bfd6f05 Bump pyOverkiz to 1.19.0 in Overkiz (#154310) 2025-10-12 18:07:19 +02:00
Shay Levy
34c5748132 Align Shelly async_setup_entry in platforms (#154142)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2025-10-12 18:41:54 +03:00
Michael Hansen
5bfd9620db Handle Wyoming config entries with missing info (#154186) 2025-10-12 10:23:09 -05:00
Michael Davie
6f8766e4bd Update config flow strings for Environment Canada (#154242) 2025-10-12 11:49:29 +02:00
Jordan Harvey
d3b519846b Bump pyprobeplus to 1.1.0 (#154265) 2025-10-12 10:06:00 +02:00
68 changed files with 957 additions and 458 deletions

1
.gitignore vendored
View File

@@ -79,6 +79,7 @@ junit.xml
.project
.pydevproject
.python-version
.tool-versions
# emacs auto backups

View File

@@ -1 +0,0 @@
3.13

View File

@@ -36,7 +36,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
USER vscode
COPY .python-version ./
ENV UV_PYTHON=3.13.2
RUN uv python install
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==6.4.1"]
"requirements": ["aioamazondevices==6.4.3"]
}

View File

@@ -36,11 +36,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo
raise ConfigEntryAuthFailed("Migration to OAuth required")
session = async_create_august_clientsession(hass)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
)
except ValueError as err:
raise ConfigEntryNotReady("OAuth implementation not available") from err
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
try:

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/control4",
"iot_class": "local_polling",
"loggers": ["pyControl4"],
"requirements": ["pyControl4==1.2.0"],
"requirements": ["pyControl4==1.5.0"],
"ssdp": [
{
"st": "c4:director"

View File

@@ -47,11 +47,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
radar_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
)
try:
await radar_coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada radar")
# Skip initial refresh for radar since the camera entity is disabled by default.
# The coordinator will fetch data when the entity is enabled.
aqhi_data = ECAirQuality(coordinates=(lat, lon))
aqhi_coordinator = ECDataUpdateCoordinator(
@@ -63,7 +60,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada AQHI")
if errors == 3:
# Require at least one coordinator to succeed (weather or AQHI)
# Radar is optional since the camera entity is disabled by default
if errors >= 2:
raise ConfigEntryNotReady
config_entry.runtime_data = ECRuntimeData(

View File

@@ -59,6 +59,14 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera
self.content_type = "image/gif"
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
# Trigger coordinator refresh when entity is enabled
# since radar coordinator skips initial refresh during setup
if not self.coordinator.last_update_success:
await self.coordinator.async_request_refresh()
def camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:

View File

@@ -6,11 +6,18 @@ import xml.etree.ElementTree as ET
import aiohttp
from env_canada import ECWeather, ec_exc
from env_canada.ec_weather import get_ec_sites_list
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_STATION, CONF_TITLE, DOMAIN
@@ -25,14 +32,16 @@ async def validate_input(data):
lang = data.get(CONF_LANGUAGE).lower()
if station:
# When station is provided, use it and get the coordinates from ECWeather
weather_data = ECWeather(station_id=station, language=lang)
else:
weather_data = ECWeather(coordinates=(lat, lon), language=lang)
await weather_data.update()
if lat is None or lon is None:
await weather_data.update()
# Always use the station's coordinates, not the user-provided ones
lat = weather_data.lat
lon = weather_data.lon
else:
# When no station is provided, use coordinates to find nearest station
weather_data = ECWeather(coordinates=(lat, lon), language=lang)
await weather_data.update()
return {
CONF_TITLE: weather_data.metadata.location,
@@ -46,6 +55,13 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Environment Canada weather."""
VERSION = 1
_station_codes: list[dict[str, str]] | None = None
async def _get_station_codes(self) -> list[dict[str, str]]:
"""Get station codes, cached after first call."""
if self._station_codes is None:
self._station_codes = await get_ec_sites_list()
return self._station_codes
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -80,9 +96,21 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info[CONF_TITLE], data=user_input)
station_codes = await self._get_station_codes()
data_schema = vol.Schema(
{
vol.Optional(CONF_STATION): str,
vol.Optional(CONF_STATION): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(
value=station["value"], label=station["label"]
)
for station in station_codes
],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(
CONF_LATITUDE, default=self.hass.config.latitude
): cv.latitude,

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.11.3"]
"requirements": ["env-canada==0.12.1"]
}

View File

@@ -3,11 +3,11 @@
"step": {
"user": {
"title": "Environment Canada: weather location and language",
"description": "Either a station ID or latitude/longitude must be specified. The default latitude/longitude used are the values configured in your Home Assistant installation. The closest weather station to the coordinates will be used if specifying coordinates. If a station code is used it must follow the format: PP/code, where PP is the two-letter province and code is the station ID. The list of station IDs can be found here: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Weather information can be retrieved in either English or French.",
"description": "Select a weather station from the dropdown, or specify coordinates to use the closest station. The default coordinates are from your Home Assistant installation. Weather information can be retrieved in English or French.",
"data": {
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"station": "Weather station ID",
"station": "Weather station",
"language": "Weather information language"
}
}
@@ -16,7 +16,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"bad_station_id": "Station ID is invalid, missing, or not found in the station ID database",
"bad_station_id": "Station code is invalid, missing, or not found in the station code database",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"error_response": "Response from Environment Canada in error",
"too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds",

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==41.13.0",
"aioesphomeapi==41.14.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -158,7 +158,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_import_kwh is not None,
value_fn=lambda data: data.measurement.energy_import_kwh,
value_fn=lambda data: data.measurement.energy_import_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t1_kwh",
@@ -172,7 +172,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
data.measurement.energy_import_t1_kwh is not None
and data.measurement.energy_export_t2_kwh is not None
),
value_fn=lambda data: data.measurement.energy_import_t1_kwh,
value_fn=lambda data: data.measurement.energy_import_t1_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t2_kwh",
@@ -182,7 +182,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_import_t2_kwh is not None,
value_fn=lambda data: data.measurement.energy_import_t2_kwh,
value_fn=lambda data: data.measurement.energy_import_t2_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t3_kwh",
@@ -192,7 +192,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_import_t3_kwh is not None,
value_fn=lambda data: data.measurement.energy_import_t3_kwh,
value_fn=lambda data: data.measurement.energy_import_t3_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t4_kwh",
@@ -202,7 +202,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_import_t4_kwh is not None,
value_fn=lambda data: data.measurement.energy_import_t4_kwh,
value_fn=lambda data: data.measurement.energy_import_t4_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_export_kwh",
@@ -212,7 +212,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_export_kwh is not None,
enabled_fn=lambda data: data.measurement.energy_export_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_kwh,
value_fn=lambda data: data.measurement.energy_export_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t1_kwh",
@@ -227,7 +227,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
and data.measurement.energy_export_t2_kwh is not None
),
enabled_fn=lambda data: data.measurement.energy_export_t1_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_t1_kwh,
value_fn=lambda data: data.measurement.energy_export_t1_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t2_kwh",
@@ -238,7 +238,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_export_t2_kwh is not None,
enabled_fn=lambda data: data.measurement.energy_export_t2_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_t2_kwh,
value_fn=lambda data: data.measurement.energy_export_t2_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t3_kwh",
@@ -249,7 +249,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_export_t3_kwh is not None,
enabled_fn=lambda data: data.measurement.energy_export_t3_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_t3_kwh,
value_fn=lambda data: data.measurement.energy_export_t3_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t4_kwh",
@@ -260,7 +260,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_export_t4_kwh is not None,
enabled_fn=lambda data: data.measurement.energy_export_t4_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_t4_kwh,
value_fn=lambda data: data.measurement.energy_export_t4_kwh or None,
),
HomeWizardSensorEntityDescription(
key="active_power_w",

View File

@@ -89,12 +89,12 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
"""Initialize AutomowerEntity."""
super().__init__(coordinator)
self.mower_id = mower_id
parts = self.mower_attributes.system.model.split(maxsplit=2)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mower_id)},
manufacturer="Husqvarna",
model=self.mower_attributes.system.model.removeprefix(
"HUSQVARNA "
).removeprefix("Husqvarna "),
manufacturer=parts[0],
model=parts[1],
model_id=parts[2],
name=self.mower_attributes.system.name,
serial_number=self.mower_attributes.system.serial_number,
suggested_area="Garden",

View File

@@ -158,6 +158,12 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
)
self._discovery_info = service_info
# Update title placeholders if name changed
name = service_info.name or service_info.address
if self.context.get("title_placeholders", {}).get("name") != name:
self.async_update_title_placeholders({"name": name})
try:
self._abort_if_provisioned()
except AbortFlow:

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/niko_home_control",
"iot_class": "local_push",
"loggers": ["nikohomecontrol"],
"requirements": ["nhc==0.6.1"]
"requirements": ["nhc==0.7.0"]
}

View File

@@ -16,7 +16,13 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .const import CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL, UNSUPPORTED_IMAGE_MODELS
from .const import (
CONF_CHAT_MODEL,
CONF_IMAGE_MODEL,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_IMAGE_MODEL,
UNSUPPORTED_IMAGE_MODELS,
)
from .entity import OpenAIBaseLLMEntity
if TYPE_CHECKING:
@@ -142,7 +148,7 @@ class OpenAITaskEntity(
mime_type=mime_type,
width=int(width) if width else None,
height=int(height) if height else None,
model="gpt-image-1",
model=self.subentry.data.get(CONF_IMAGE_MODEL, RECOMMENDED_IMAGE_MODEL),
revised_prompt=image_call.revised_prompt
if hasattr(image_call, "revised_prompt")
else None,

View File

@@ -43,6 +43,7 @@ from homeassistant.helpers.typing import VolDictType
from .const import (
CONF_CHAT_MODEL,
CONF_CODE_INTERPRETER,
CONF_IMAGE_MODEL,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_REASONING_EFFORT,
@@ -64,6 +65,7 @@ from .const import (
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_CODE_INTERPRETER,
RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_IMAGE_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_TEMPERATURE,
@@ -72,6 +74,7 @@ from .const import (
RECOMMENDED_WEB_SEARCH,
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
RECOMMENDED_WEB_SEARCH_USER_LOCATION,
UNSUPPORTED_IMAGE_MODELS,
UNSUPPORTED_MODELS,
UNSUPPORTED_WEB_SEARCH_MODELS,
)
@@ -411,6 +414,18 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
)
}
if self._subentry_type == "ai_task_data" and not model.startswith(
tuple(UNSUPPORTED_IMAGE_MODELS)
):
step_schema[
vol.Optional(CONF_IMAGE_MODEL, default=RECOMMENDED_IMAGE_MODEL)
] = SelectSelector(
SelectSelectorConfig(
options=["gpt-image-1", "gpt-image-1-mini"],
mode=SelectSelectorMode.DROPDOWN,
)
)
if user_input is not None:
if user_input.get(CONF_WEB_SEARCH):
if user_input.get(CONF_WEB_SEARCH_USER_LOCATION):

View File

@@ -13,6 +13,7 @@ DEFAULT_AI_TASK_NAME = "OpenAI AI Task"
DEFAULT_NAME = "OpenAI Conversation"
CONF_CHAT_MODEL = "chat_model"
CONF_IMAGE_MODEL = "image_model"
CONF_CODE_INTERPRETER = "code_interpreter"
CONF_FILENAMES = "filenames"
CONF_MAX_TOKENS = "max_tokens"
@@ -31,6 +32,7 @@ CONF_WEB_SEARCH_COUNTRY = "country"
CONF_WEB_SEARCH_TIMEZONE = "timezone"
RECOMMENDED_CODE_INTERPRETER = False
RECOMMENDED_CHAT_MODEL = "gpt-4o-mini"
RECOMMENDED_IMAGE_MODEL = "gpt-image-1"
RECOMMENDED_MAX_TOKENS = 3000
RECOMMENDED_REASONING_EFFORT = "low"
RECOMMENDED_TEMPERATURE = 1.0

View File

@@ -67,6 +67,7 @@ from homeassistant.util import slugify
from .const import (
CONF_CHAT_MODEL,
CONF_CODE_INTERPRETER,
CONF_IMAGE_MODEL,
CONF_MAX_TOKENS,
CONF_REASONING_EFFORT,
CONF_TEMPERATURE,
@@ -82,6 +83,7 @@ from .const import (
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_IMAGE_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_TEMPERATURE,
@@ -516,13 +518,15 @@ class OpenAIBaseLLMEntity(Entity):
model_args.setdefault("include", []).append("code_interpreter_call.outputs") # type: ignore[union-attr]
if force_image:
tools.append(
ImageGeneration(
type="image_generation",
input_fidelity="high",
output_format="png",
)
image_model = options.get(CONF_IMAGE_MODEL, RECOMMENDED_IMAGE_MODEL)
image_tool = ImageGeneration(
type="image_generation",
model=image_model,
output_format="png",
)
if image_model == "gpt-image-1":
image_tool["input_fidelity"] = "high"
tools.append(image_tool)
model_args["tool_choice"] = ToolChoiceTypesParam(type="image_generation")
model_args["store"] = True # Avoid sending image data back and forth

View File

@@ -50,6 +50,7 @@
"data": {
"code_interpreter": "Enable code interpreter tool",
"reasoning_effort": "Reasoning effort",
"image_model": "Image generation model",
"web_search": "Enable web search",
"search_context_size": "Search context size",
"user_location": "Include home location"
@@ -57,6 +58,7 @@
"data_description": {
"code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions",
"reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt",
"image_model": "The model to use when generating images",
"web_search": "Allow the model to search the web for the latest information before generating a response",
"search_context_size": "High level guidance for the amount of context window space to use for the search",
"user_location": "Refine search results based on geography"
@@ -97,12 +99,14 @@
"title": "[%key:component::openai_conversation::config_subentries::conversation::step::model::title%]",
"data": {
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]",
"image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::image_model%]",
"web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]",
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]",
"user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]"
},
"data_description": {
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]",
"image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::image_model%]",
"web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]",
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]",
"user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::user_location%]"

View File

@@ -210,7 +210,9 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the local authentication step via config flow."""
errors = {}
description_placeholders = {}
description_placeholders = {
"somfy-developer-mode-docs": "https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started"
}
if user_input:
self._host = user_input[CONF_HOST]

View File

@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.17.2"],
"requirements": ["pyoverkiz==1.19.0"],
"zeroconf": [
{
"type": "_kizbox._tcp.local.",

View File

@@ -32,7 +32,7 @@
}
},
"local": {
"description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway.",
"description": "By activating the [Developer Mode of your TaHoma box]({somfy-developer-mode-docs}), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"token": "[%key:common::config_flow::data::api_token%]",

View File

@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.7.8"],
"requirements": ["plugwise==1.8.0"],
"zeroconf": ["_plugwise._tcp.local."]
}

View File

@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["pyprobeplus==1.0.1"]
"requirements": ["pyprobeplus==1.1.0"]
}

View File

@@ -1,19 +1,19 @@
"""Helper functions for Prowl."""
import asyncio
from functools import partial
import prowlpy
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
async def async_verify_key(hass: HomeAssistant, api_key: str) -> bool:
"""Validate API key."""
prowl = await hass.async_add_executor_job(partial(prowlpy.Prowl, api_key))
prowl = prowlpy.AsyncProwl(api_key, client=get_async_client(hass))
try:
async with asyncio.timeout(10):
await hass.async_add_executor_job(prowl.verify_key)
await prowl.verify_key()
return True
except prowlpy.APIError as ex:
if str(ex).startswith("Invalid API key"):

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["prowl"],
"quality_scale": "legacy",
"requirements": ["prowlpy==1.0.2"]
"requirements": ["prowlpy==1.1.1"]
}

View File

@@ -3,10 +3,10 @@
from __future__ import annotations
import asyncio
from functools import partial
import logging
from typing import Any
import httpx
import prowlpy
import voluptuous as vol
@@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
@@ -37,9 +38,7 @@ async def async_get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> ProwlNotificationService:
"""Get the Prowl notification service."""
return await hass.async_add_executor_job(
partial(ProwlNotificationService, hass, config[CONF_API_KEY])
)
return ProwlNotificationService(hass, config[CONF_API_KEY], get_async_client(hass))
async def async_setup_entry(
@@ -48,7 +47,9 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the notify entities."""
prowl = ProwlNotificationEntity(hass, entry.title, entry.data[CONF_API_KEY])
prowl = ProwlNotificationEntity(
hass, entry.title, entry.data[CONF_API_KEY], get_async_client(hass)
)
async_add_entities([prowl])
@@ -58,10 +59,12 @@ class ProwlNotificationService(BaseNotificationService):
This class is used for legacy configuration via configuration.yaml
"""
def __init__(self, hass: HomeAssistant, api_key: str) -> None:
def __init__(
self, hass: HomeAssistant, api_key: str, httpx_client: httpx.AsyncClient
) -> None:
"""Initialize the service."""
self._hass = hass
self._prowl = prowlpy.Prowl(api_key)
self._prowl = prowlpy.AsyncProwl(api_key, client=httpx_client)
async def async_send_message(self, message: str, **kwargs: Any) -> None:
"""Send the message to the user."""
@@ -71,15 +74,12 @@ class ProwlNotificationService(BaseNotificationService):
try:
async with asyncio.timeout(10):
await self._hass.async_add_executor_job(
partial(
self._prowl.send,
application="Home-Assistant",
event=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
description=message,
priority=data.get("priority", 0),
url=data.get("url"),
)
await self._prowl.post(
application="Home-Assistant",
event=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
description=message,
priority=data.get("priority", 0),
url=data.get("url"),
)
except TimeoutError as ex:
_LOGGER.error("Timeout accessing Prowl API")
@@ -103,10 +103,16 @@ class ProwlNotificationEntity(NotifyEntity):
This class is used for Prowl config entries.
"""
def __init__(self, hass: HomeAssistant, name: str, api_key: str) -> None:
def __init__(
self,
hass: HomeAssistant,
name: str,
api_key: str,
httpx_client: httpx.AsyncClient,
) -> None:
"""Initialize the service."""
self._hass = hass
self._prowl = prowlpy.Prowl(api_key)
self._prowl = prowlpy.AsyncProwl(api_key, client=httpx_client)
self._attr_name = name
self._attr_unique_id = name
@@ -115,15 +121,12 @@ class ProwlNotificationEntity(NotifyEntity):
_LOGGER.debug("Sending Prowl notification from entity %s", self.name)
try:
async with asyncio.timeout(10):
await self._hass.async_add_executor_job(
partial(
self._prowl.send,
application="Home-Assistant",
event=title or ATTR_TITLE_DEFAULT,
description=message,
priority=0,
url=None,
)
await self._prowl.post(
application="Home-Assistant",
event=title or ATTR_TITLE_DEFAULT,
description=message,
priority=0,
url=None,
)
except TimeoutError as ex:
_LOGGER.error("Timeout accessing Prowl API")

View File

@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -337,33 +337,20 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for device."""
"""Set up binary sensor entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc(
hass,
config_entry,
async_add_entities,
RPC_SENSORS,
RpcSleepingBinarySensor,
)
else:
coordinator = config_entry.runtime_data.rpc
assert coordinator
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_SENSORS, RpcBinarySensor
)
return _async_setup_block_entry(hass, config_entry, async_add_entities)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
BINARY_SENSOR_PLATFORM,
coordinator.device.status,
)
return
@callback
def _async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for BLOCK device."""
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_attribute_entities(
hass,
@@ -389,6 +376,38 @@ async def async_setup_entry(
)
@callback
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc(
hass,
config_entry,
async_add_entities,
RPC_SENSORS,
RpcSleepingBinarySensor,
)
else:
coordinator = config_entry.runtime_data.rpc
assert coordinator
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_SENSORS, RpcBinarySensor
)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
BINARY_SENSOR_PLATFORM,
coordinator.device.status,
)
class BlockBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
"""Represent a block binary sensor entity."""

View File

@@ -173,7 +173,7 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set buttons for device."""
"""Set up button entities."""
entry_data = config_entry.runtime_data
coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:

View File

@@ -269,27 +269,36 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate device."""
"""Set up climate entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
async_setup_rpc_entry(hass, config_entry, async_add_entities)
return
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
return _async_setup_block_entry(hass, config_entry, async_add_entities)
@callback
def _async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for BLOCK device."""
coordinator = config_entry.runtime_data.block
assert coordinator
if coordinator.device.initialized:
async_setup_climate_entities(async_add_entities, coordinator)
_async_setup_block_climate_entities(async_add_entities, coordinator)
else:
async_restore_climate_entities(
_async_restore_block_climate_entities(
hass, config_entry, async_add_entities, coordinator
)
@callback
def async_setup_climate_entities(
def _async_setup_block_climate_entities(
async_add_entities: AddConfigEntryEntitiesCallback,
coordinator: ShellyBlockCoordinator,
) -> None:
"""Set up online climate devices."""
"""Set up online BLOCK climate devices."""
device_block: Block | None = None
sensor_block: Block | None = None
@@ -310,13 +319,13 @@ def async_setup_climate_entities(
@callback
def async_restore_climate_entities(
def _async_restore_block_climate_entities(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
coordinator: ShellyBlockCoordinator,
) -> None:
"""Restore sleeping climate devices."""
"""Restore sleeping BLOCK climate devices."""
ent_reg = er.async_get(hass)
entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
@@ -332,7 +341,7 @@ def async_restore_climate_entities(
@callback
def async_setup_rpc_entry(
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,

View File

@@ -63,20 +63,20 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up covers for device."""
"""Set up cover entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
return async_setup_rpc_entry(hass, config_entry, async_add_entities)
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
return async_setup_block_entry(hass, config_entry, async_add_entities)
return _async_setup_block_entry(hass, config_entry, async_add_entities)
@callback
def async_setup_block_entry(
def _async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up cover for device."""
"""Set up entities for BLOCK device."""
coordinator = config_entry.runtime_data.block
assert coordinator
@@ -86,7 +86,7 @@ def async_setup_block_entry(
@callback
def async_setup_rpc_entry(
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,

View File

@@ -84,71 +84,91 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for device."""
entities: list[ShellyBlockEvent | ShellyRpcEvent] = []
coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None
"""Set up event entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
coordinator = config_entry.runtime_data.rpc
if TYPE_CHECKING:
assert coordinator
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
key_instances = get_rpc_key_instances(coordinator.device.status, RPC_EVENT.key)
return _async_setup_block_entry(hass, config_entry, async_add_entities)
for key in key_instances:
if RPC_EVENT.removal_condition and RPC_EVENT.removal_condition(
coordinator.device.config, coordinator.device.status, key
):
unique_id = f"{coordinator.mac}-{key}"
async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id)
else:
entities.append(ShellyRpcEvent(coordinator, key, RPC_EVENT))
script_instances = get_rpc_key_instances(
coordinator.device.status, SCRIPT_EVENT.key
)
script_events = config_entry.runtime_data.rpc_script_events
for script in script_instances:
script_name = get_rpc_entity_name(coordinator.device, script)
if script_name == BLE_SCRIPT_NAME:
continue
@callback
def _async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for BLOCK device."""
entities: list[ShellyBlockEvent] = []
script_id = int(script.split(":")[-1])
if script_events and (event_types := script_events[script_id]):
entities.append(ShellyRpcScriptEvent(coordinator, script, event_types))
coordinator = config_entry.runtime_data.block
if TYPE_CHECKING:
assert coordinator and coordinator.device.blocks
# If a script is removed, from the device configuration, we need to remove orphaned entities
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
EVENT_DOMAIN,
coordinator.device.status,
"script",
)
for block in coordinator.device.blocks:
if (
"inputEvent" not in block.sensor_ids
or "inputEventCnt" not in block.sensor_ids
):
continue
else:
coordinator = config_entry.runtime_data.block
if TYPE_CHECKING:
assert coordinator
assert coordinator.device.blocks
if BLOCK_EVENT.removal_condition and BLOCK_EVENT.removal_condition(
coordinator.device.settings, block
):
channel = int(block.channel or 0) + 1
unique_id = f"{coordinator.mac}-{block.description}-{channel}"
async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id)
else:
entities.append(ShellyBlockEvent(coordinator, block, BLOCK_EVENT))
for block in coordinator.device.blocks:
if (
"inputEvent" not in block.sensor_ids
or "inputEventCnt" not in block.sensor_ids
):
continue
async_add_entities(entities)
if BLOCK_EVENT.removal_condition and BLOCK_EVENT.removal_condition(
coordinator.device.settings, block
):
channel = int(block.channel or 0) + 1
unique_id = f"{coordinator.mac}-{block.description}-{channel}"
async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id)
else:
entities.append(ShellyBlockEvent(coordinator, block, BLOCK_EVENT))
@callback
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
entities: list[ShellyRpcEvent] = []
coordinator = config_entry.runtime_data.rpc
if TYPE_CHECKING:
assert coordinator
key_instances = get_rpc_key_instances(coordinator.device.status, RPC_EVENT.key)
for key in key_instances:
if RPC_EVENT.removal_condition and RPC_EVENT.removal_condition(
coordinator.device.config, coordinator.device.status, key
):
unique_id = f"{coordinator.mac}-{key}"
async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id)
else:
entities.append(ShellyRpcEvent(coordinator, key, RPC_EVENT))
script_instances = get_rpc_key_instances(
coordinator.device.status, SCRIPT_EVENT.key
)
script_events = config_entry.runtime_data.rpc_script_events
for script in script_instances:
script_name = get_rpc_entity_name(coordinator.device, script)
if script_name == BLE_SCRIPT_NAME:
continue
script_id = int(script.split(":")[-1])
if script_events and (event_types := script_events[script_id]):
entities.append(ShellyRpcScriptEvent(coordinator, script, event_types))
# If a script is removed, from the device configuration, we need to remove orphaned entities
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
EVENT_DOMAIN,
coordinator.device.status,
"script",
)
async_add_entities(entities)

View File

@@ -82,20 +82,20 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lights for device."""
"""Set up light entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
return async_setup_rpc_entry(hass, config_entry, async_add_entities)
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
return async_setup_block_entry(hass, config_entry, async_add_entities)
return _async_setup_block_entry(hass, config_entry, async_add_entities)
@callback
def async_setup_block_entry(
def _async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for block device."""
"""Set up entities for BLOCK device."""
coordinator = config_entry.runtime_data.block
assert coordinator
@@ -538,7 +538,7 @@ LIGHTS: Final = {
@callback
def async_setup_rpc_entry(
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,

View File

@@ -9,7 +9,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "silver",
"requirements": ["aioshelly==13.12.0"],
"requirements": ["aioshelly==13.13.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",

View File

@@ -19,7 +19,7 @@ from homeassistant.components.number import (
RestoreNumber,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
@@ -331,30 +331,20 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up numbers for device."""
"""Set up number entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
coordinator = config_entry.runtime_data.rpc
assert coordinator
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_NUMBERS, RpcNumber
)
return _async_setup_block_entry(hass, config_entry, async_add_entities)
# the user can remove virtual components from the device configuration, so
# we need to remove orphaned entities
virtual_number_ids = get_virtual_component_ids(
coordinator.device.config, NUMBER_PLATFORM
)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
NUMBER_PLATFORM,
virtual_number_ids,
"number",
)
return
@callback
def _async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for BLOCK device."""
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_attribute_entities(
hass,
@@ -365,6 +355,35 @@ async def async_setup_entry(
)
@callback
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
coordinator = config_entry.runtime_data.rpc
assert coordinator
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_NUMBERS, RpcNumber
)
# the user can remove virtual components from the device configuration, so
# we need to remove orphaned entities
virtual_number_ids = get_virtual_component_ids(
coordinator.device.config, NUMBER_PLATFORM
)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
NUMBER_PLATFORM,
virtual_number_ids,
"number",
)
class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
"""Represent a block sleeping number."""

View File

@@ -12,7 +12,7 @@ from homeassistant.components.select import (
SelectEntity,
SelectEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator
@@ -54,28 +54,40 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up selectors for device."""
"""Set up select entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
coordinator = config_entry.runtime_data.rpc
assert coordinator
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_SELECT_ENTITIES, RpcSelect
)
return None
# the user can remove virtual components from the device configuration, so
# we need to remove orphaned entities
virtual_text_ids = get_virtual_component_ids(
coordinator.device.config, SELECT_PLATFORM
)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
SELECT_PLATFORM,
virtual_text_ids,
"enum",
)
@callback
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
coordinator = config_entry.runtime_data.rpc
assert coordinator
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_SELECT_ENTITIES, RpcSelect
)
# the user can remove virtual components from the device configuration, so
# we need to remove orphaned entities
virtual_text_ids = get_virtual_component_ids(
coordinator.device.config, SELECT_PLATFORM
)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
SELECT_PLATFORM,
virtual_text_ids,
"enum",
)
class RpcSelect(ShellyRpcAttributeEntity, SelectEntity):

View File

@@ -36,7 +36,7 @@ from homeassistant.const import (
UnitOfVolume,
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType
@@ -1710,33 +1710,20 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for device."""
"""Set up sensor entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc(
hass,
config_entry,
async_add_entities,
RPC_SENSORS,
RpcSleepingSensor,
)
else:
coordinator = config_entry.runtime_data.rpc
assert coordinator
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor
)
return _async_setup_block_entry(hass, config_entry, async_add_entities)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
SENSOR_PLATFORM,
coordinator.device.status,
)
return
@callback
def _async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for BLOCK device."""
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_attribute_entities(
hass,
@@ -1758,6 +1745,38 @@ async def async_setup_entry(
)
@callback
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc(
hass,
config_entry,
async_add_entities,
RPC_SENSORS,
RpcSleepingSensor,
)
else:
coordinator = config_entry.runtime_data.rpc
assert coordinator
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor
)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
SENSOR_PLATFORM,
coordinator.device.status,
)
class BlockSensor(ShellyBlockAttributeEntity, SensorEntity):
"""Represent a block sensor."""

View File

@@ -264,20 +264,20 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches for device."""
"""Set up switch entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
return async_setup_rpc_entry(hass, config_entry, async_add_entities)
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
return async_setup_block_entry(hass, config_entry, async_add_entities)
return _async_setup_block_entry(hass, config_entry, async_add_entities)
@callback
def async_setup_block_entry(
def _async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for block device."""
"""Set up entities for BLOCK device."""
coordinator = config_entry.runtime_data.block
assert coordinator
@@ -295,7 +295,7 @@ def async_setup_block_entry(
@callback
def async_setup_rpc_entry(
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,

View File

@@ -12,7 +12,7 @@ from homeassistant.components.text import (
TextEntity,
TextEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ShellyConfigEntry
@@ -54,28 +54,40 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for device."""
"""Set up text entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
coordinator = config_entry.runtime_data.rpc
assert coordinator
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_TEXT_ENTITIES, RpcText
)
return None
# the user can remove virtual components from the device configuration, so
# we need to remove orphaned entities
virtual_text_ids = get_virtual_component_ids(
coordinator.device.config, TEXT_PLATFORM
)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
TEXT_PLATFORM,
virtual_text_ids,
"text",
)
@callback
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
coordinator = config_entry.runtime_data.rpc
assert coordinator
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_TEXT_ENTITIES, RpcText
)
# the user can remove virtual components from the device configuration, so
# we need to remove orphaned entities
virtual_text_ids = get_virtual_component_ids(
coordinator.device.config, TEXT_PLATFORM
)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
TEXT_PLATFORM,
virtual_text_ids,
"text",
)
class RpcText(ShellyRpcAttributeEntity, TextEntity):

View File

@@ -115,22 +115,20 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up update entities for Shelly component."""
"""Set up update entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc(
hass,
config_entry,
async_add_entities,
RPC_UPDATES,
RpcSleepingUpdateEntity,
)
else:
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_UPDATES, RpcUpdateEntity
)
return
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
return _async_setup_block_entry(hass, config_entry, async_add_entities)
@callback
def _async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for BLOCK device."""
if not config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rest(
hass,
@@ -141,6 +139,27 @@ async def async_setup_entry(
)
@callback
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc(
hass,
config_entry,
async_add_entities,
RPC_UPDATES,
RpcSleepingUpdateEntity,
)
else:
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_UPDATES, RpcUpdateEntity
)
class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity):
"""Represent a REST update entity."""

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
from typing import Any, cast
from aioshelly.block_device import Block
from aioshelly.const import BLOCK_GENERATIONS, MODEL_GAS
from aioshelly.const import MODEL_GAS, RPC_GENERATIONS
from homeassistant.components.valve import (
ValveDeviceClass,
@@ -135,15 +135,34 @@ async def async_setup_entry(
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up valves for device."""
if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS:
return async_setup_block_entry(hass, config_entry, async_add_entities)
"""Set up valve entities."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
return _async_setup_rpc_entry(hass, config_entry, async_add_entities)
return async_setup_rpc_entry(hass, config_entry, async_add_entities)
return _async_setup_block_entry(hass, config_entry, async_add_entities)
@callback
def async_setup_rpc_entry(
def _async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for BLOCK device."""
coordinator = config_entry.runtime_data.block
assert coordinator
async_setup_block_attribute_entities(
hass,
async_add_entities,
coordinator,
BLOCK_VALVES,
BlockShellyValve,
)
@callback
def _async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
@@ -157,25 +176,6 @@ def async_setup_rpc_entry(
)
@callback
def async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up valve for device."""
coordinator = config_entry.runtime_data.block
assert coordinator
async_setup_block_attribute_entities(
hass,
async_add_entities,
coordinator,
BLOCK_VALVES,
BlockShellyValve,
)
class BlockShellyValve(ShellyBlockAttributeEntity, ValveEntity):
"""Entity that controls a valve on block based Shelly devices."""

View File

@@ -76,6 +76,7 @@
"name": "State",
"state": {
"baseline": "Baseline",
"weaning_baseline": "Baseline (Weaning)",
"level1": "Level 1",
"level2": "Level 2",
"level3": "Level 3",

View File

@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/tilt_ble",
"iot_class": "local_push",
"requirements": ["tilt-ble==0.3.1"]
"requirements": ["tilt-ble==1.0.1"]
}

View File

@@ -70,17 +70,13 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
uri = urlparse(discovery_info.config["uri"])
for entry in self._async_current_entries(include_ignore=True):
if (
entry.data[CONF_HOST] == uri.hostname
and entry.data[CONF_PORT] == uri.port
):
return self.async_update_reload_and_abort(
entry,
unique_id=discovery_info.uuid,
reload_even_if_entry_is_unchanged=False,
reason="already_configured",
)
for entry in self._iter_entries(uri.hostname, uri.port):
return self.async_update_reload_and_abort(
entry,
unique_id=discovery_info.uuid,
reload_even_if_entry_is_unchanged=False,
reason="already_configured",
)
self._hassio_discovery = discovery_info
self.context.update(
@@ -139,12 +135,8 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN):
self.context["title_placeholders"] = {"name": self._name}
for entry in self._async_current_entries(include_ignore=True):
if (
entry.data[CONF_HOST] == service.host
and entry.data[CONF_PORT] == service.port
and entry.source != SOURCE_HASSIO
):
for entry in self._iter_entries(service.host, service.port):
if entry.source != SOURCE_HASSIO:
return self.async_update_reload_and_abort(
entry,
unique_id=unique_id,
@@ -176,3 +168,9 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PORT: self._service.port,
},
)
def _iter_entries(self, host: str, port: int):
"""Yield entries with matching host/port."""
for entry in self._async_current_entries(include_ignore=True):
if entry.data.get(CONF_HOST) == host and entry.data.get(CONF_PORT) == port:
yield entry

View File

@@ -27,13 +27,16 @@ type YaleConfigEntry = ConfigEntry[YaleData]
async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool:
"""Set up yale from a config entry."""
"""Set up Yale from a config entry."""
session = async_create_yale_clientsession(hass)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
)
except ValueError as err:
raise ConfigEntryNotReady("OAuth implementation not available") from err
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
yale_gateway = YaleGateway(Path(hass.config.config_dir), session, oauth_session)
try:

View File

@@ -2859,6 +2859,29 @@ class ConfigFlow(ConfigEntryBaseFlow):
"""Return subentries supported by this handler."""
return {}
@callback
def async_update_title_placeholders(
self, title_placeholders: Mapping[str, str]
) -> None:
"""Update title placeholders for the discovery notification and notify listeners.
This updates the flow context title_placeholders and notifies listeners
(such as the frontend) to reload the flow state, updating the discovery
notification title.
Only call this method when the flow is not progressing to a new step
(e.g., from a callback that receives updated data). If the flow is
progressing to a new step, set title_placeholders directly in context
before returning the step result, as the step change will trigger
listener notification automatically.
"""
# Context is typed as TypedDict but is mutable dict at runtime
current_placeholders = cast(
dict[str, str], self.context.setdefault("title_placeholders", {})
)
current_placeholders.update(title_placeholders)
self.async_notify_flow_changed()
@callback
def _async_abort_entries_match(
self, match_dict: dict[str, Any] | None = None

View File

@@ -432,11 +432,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
!= result.get("description_placeholders")
)
):
# Tell frontend to reload the flow state.
self.hass.bus.async_fire_internal(
EVENT_DATA_ENTRY_FLOW_PROGRESSED,
{"handler": flow.handler, "flow_id": flow_id, "refresh": True},
)
flow.async_notify_flow_changed()
return result
@@ -886,6 +882,17 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
{"handler": self.handler, "flow_id": self.flow_id, "progress": progress},
)
@callback
def async_notify_flow_changed(self) -> None:
"""Notify listeners that the flow has changed.
This notifies listeners (such as the frontend) to reload the flow state.
"""
self.hass.bus.async_fire_internal(
EVENT_DATA_ENTRY_FLOW_PROGRESSED,
{"handler": self.handler, "flow_id": self.flow_id, "refresh": True},
)
@callback
def async_show_progress_done(self, *, next_step_id: str) -> _FlowResultT:
"""Mark the progress done."""

View File

@@ -161,6 +161,7 @@ class-const-naming-style = "any"
# possibly-used-before-assignment - too many errors / not necessarily issues
# ---
# Pylint CodeStyle plugin
# consider-math-not-float
# consider-using-namedtuple-or-dataclass - too opinionated
# consider-using-assignment-expr - decision to use := better left to devs
disable = [
@@ -181,6 +182,7 @@ disable = [
"too-many-boolean-expressions",
"too-many-positional-arguments",
"wrong-import-order",
"consider-math-not-float",
"consider-using-namedtuple-or-dataclass",
"consider-using-assignment-expr",
"possibly-used-before-assignment",

22
requirements_all.txt generated
View File

@@ -185,7 +185,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.1
# homeassistant.components.alexa_devices
aioamazondevices==6.4.1
aioamazondevices==6.4.3
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==41.13.0
aioesphomeapi==41.14.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -384,7 +384,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.12.0
aioshelly==13.13.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -895,7 +895,7 @@ enocean==0.50
enturclient==0.2.4
# homeassistant.components.environment_canada
env-canada==0.11.3
env-canada==0.12.1
# homeassistant.components.season
ephem==4.1.6
@@ -1545,7 +1545,7 @@ nextcord==3.1.0
nextdns==4.1.0
# homeassistant.components.niko_home_control
nhc==0.6.1
nhc==0.7.0
# homeassistant.components.nibe_heatpump
nibe==2.19.0
@@ -1720,7 +1720,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.7.8
plugwise==1.8.0
# homeassistant.components.serial_pm
pmsensor==0.4
@@ -1744,7 +1744,7 @@ proliphix==0.4.1
prometheus-client==0.21.0
# homeassistant.components.prowl
prowlpy==1.0.2
prowlpy==1.1.1
# homeassistant.components.proxmoxve
proxmoxer==2.0.1
@@ -1815,7 +1815,7 @@ pyAtome==0.1.1
pyCEC==0.5.2
# homeassistant.components.control4
pyControl4==1.2.0
pyControl4==1.5.0
# homeassistant.components.duotecno
pyDuotecno==2024.10.1
@@ -2263,7 +2263,7 @@ pyotgw==2.2.2
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.17.2
pyoverkiz==1.19.0
# homeassistant.components.onewire
pyownet==0.10.0.post1
@@ -2296,7 +2296,7 @@ pypoint==3.0.0
pyportainer==1.0.3
# homeassistant.components.probe_plus
pyprobeplus==1.0.1
pyprobeplus==1.1.0
# homeassistant.components.profiler
pyprof2calltree==1.4.5
@@ -2991,7 +2991,7 @@ thinqconnect==1.0.8
tikteck==0.4
# homeassistant.components.tilt_ble
tilt-ble==0.3.1
tilt-ble==1.0.1
# homeassistant.components.tilt_pi
tilt-pi==0.2.1

View File

@@ -7,7 +7,7 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
astroid==3.3.11
astroid==4.0.1
coverage==7.10.6
freezegun==1.5.2
go2rtc-client==0.2.1
@@ -18,7 +18,7 @@ mock-open==1.4.0
mypy-dev==1.19.0a4
pre-commit==4.2.0
pydantic==2.12.0
pylint==3.3.9
pylint==4.0.0
pylint-per-file-ignores==1.4.0
pipdeptree==2.26.1
pytest-asyncio==1.2.0

View File

@@ -173,7 +173,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.1
# homeassistant.components.alexa_devices
aioamazondevices==6.4.1
aioamazondevices==6.4.3
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==41.13.0
aioesphomeapi==41.14.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -366,7 +366,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.12.0
aioshelly==13.13.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -777,7 +777,7 @@ energyzero==2.1.1
enocean==0.50
# homeassistant.components.environment_canada
env-canada==0.11.3
env-canada==0.12.1
# homeassistant.components.season
ephem==4.1.6
@@ -1328,7 +1328,7 @@ nextcord==3.1.0
nextdns==4.1.0
# homeassistant.components.niko_home_control
nhc==0.6.1
nhc==0.7.0
# homeassistant.components.nibe_heatpump
nibe==2.19.0
@@ -1461,7 +1461,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.7.8
plugwise==1.8.0
# homeassistant.components.poolsense
poolsense==0.0.8
@@ -1479,7 +1479,7 @@ prayer-times-calculator-offline==1.0.3
prometheus-client==0.21.0
# homeassistant.components.prowl
prowlpy==1.0.2
prowlpy==1.1.1
# homeassistant.components.hardware
# homeassistant.components.recorder
@@ -1538,7 +1538,7 @@ py-synologydsm-api==2.7.3
pyCEC==0.5.2
# homeassistant.components.control4
pyControl4==1.2.0
pyControl4==1.5.0
# homeassistant.components.duotecno
pyDuotecno==2024.10.1
@@ -1893,7 +1893,7 @@ pyotgw==2.2.2
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.17.2
pyoverkiz==1.19.0
# homeassistant.components.onewire
pyownet==0.10.0.post1
@@ -1923,7 +1923,7 @@ pypoint==3.0.0
pyportainer==1.0.3
# homeassistant.components.probe_plus
pyprobeplus==1.0.1
pyprobeplus==1.1.0
# homeassistant.components.profiler
pyprof2calltree==1.4.5
@@ -2474,7 +2474,7 @@ thermopro-ble==0.13.1
thinqconnect==1.0.8
# homeassistant.components.tilt_ble
tilt-ble==0.3.1
tilt-ble==1.0.1
# homeassistant.components.tilt_pi
tilt-pi==0.2.1

View File

@@ -1,6 +1,6 @@
"""The tests for the august platform."""
from unittest.mock import Mock
from unittest.mock import Mock, patch
from aiohttp import ClientResponseError
import pytest
@@ -33,6 +33,8 @@ from .mocks import (
_mock_inoperative_august_lock_detail,
_mock_lock_with_offline_key,
_mock_operative_august_lock_detail,
mock_august_config_entry,
mock_client_credentials,
)
from tests.common import MockConfigEntry
@@ -284,3 +286,18 @@ async def test_oauth_migration_on_legacy_entry(hass: HomeAssistant) -> None:
assert len(flows) == 1
assert flows[0]["step_id"] == "pick_implementation"
assert flows[0]["context"]["source"] == "reauth"
async def test_oauth_implementation_not_available(hass: HomeAssistant) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
await mock_client_credentials(hass)
entry = await mock_august_config_entry(hass)
with patch(
"homeassistant.components.august.config_entry_oauth2_flow.async_get_config_entry_implementation",
side_effect=ValueError("Implementation not available"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -15,12 +15,17 @@ from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
FAKE_CONFIG = {
CONF_STATION: "ON/s1234567",
CONF_STATION: "123",
CONF_LANGUAGE: "English",
CONF_LATITUDE: 42.42,
CONF_LONGITUDE: -42.42,
}
FAKE_TITLE = "Universal title!"
FAKE_STATIONS = [
{"label": "Toronto, ON", "value": "123"},
{"label": "Ottawa, ON", "value": "456"},
{"label": "Montreal, QC", "value": "789"},
]
def mocked_ec():
@@ -40,10 +45,19 @@ def mocked_ec():
)
def mocked_stations():
"""Mock the station list."""
return patch(
"homeassistant.components.environment_canada.config_flow.get_ec_sites_list",
return_value=FAKE_STATIONS,
)
async def test_create_entry(hass: HomeAssistant) -> None:
"""Test creating an entry."""
with (
mocked_ec(),
mocked_stations(),
patch(
"homeassistant.components.environment_canada.async_setup_entry",
return_value=True,
@@ -66,12 +80,13 @@ async def test_create_same_entry_twice(hass: HomeAssistant) -> None:
entry = MockConfigEntry(
domain=DOMAIN,
data=FAKE_CONFIG,
unique_id="ON/s1234567-english",
unique_id="123-english",
)
entry.add_to_hass(hass)
with (
mocked_ec(),
mocked_stations(),
patch(
"homeassistant.components.environment_canada.async_setup_entry",
return_value=True,
@@ -101,9 +116,12 @@ async def test_create_same_entry_twice(hass: HomeAssistant) -> None:
async def test_exception_handling(hass: HomeAssistant, error) -> None:
"""Test exception handling."""
exc, base_error = error
with patch(
"homeassistant.components.environment_canada.config_flow.ECWeather",
side_effect=exc,
with (
mocked_stations(),
patch(
"homeassistant.components.environment_canada.config_flow.ECWeather",
side_effect=exc,
),
):
flow = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -121,6 +139,7 @@ async def test_lat_lon_not_specified(hass: HomeAssistant) -> None:
"""Test that the import step works when coordinates are not specified."""
with (
mocked_ec(),
mocked_stations(),
patch(
"homeassistant.components.environment_canada.async_setup_entry",
return_value=True,
@@ -136,3 +155,31 @@ async def test_lat_lon_not_specified(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == FAKE_CONFIG
assert result["title"] == FAKE_TITLE
async def test_coordinates_without_station(hass: HomeAssistant) -> None:
"""Test setup with coordinates but no station ID."""
with (
mocked_ec(),
mocked_stations(),
patch(
"homeassistant.components.environment_canada.async_setup_entry",
return_value=True,
),
):
# Config with coordinates but no station
config_no_station = {
CONF_LANGUAGE: "English",
CONF_LATITUDE: 42.42,
CONF_LONGITUDE: -42.42,
}
flow = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
flow["flow_id"], config_no_station
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == FAKE_CONFIG
assert result["title"] == FAKE_TITLE

View File

@@ -32,7 +32,6 @@ from homeassistant.components.stream import (
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import (
CONF_AUTHENTICATION,
CONF_NAME,
CONF_PASSWORD,
CONF_USERNAME,
CONF_VERIFY_SSL,
@@ -56,16 +55,17 @@ TESTDATA = {
CONF_VERIFY_SSL: False,
}
TESTDATA_ONLYSTILL = TESTDATA.copy()
TESTDATA_ONLYSTILL.pop(CONF_STREAM_SOURCE)
TESTDATA_ONLYSTREAM = TESTDATA.copy()
TESTDATA_ONLYSTREAM.pop(CONF_STILL_IMAGE_URL)
TESTDATA_OPTIONS = {
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
**TESTDATA,
}
TESTDATA_YAML = {
CONF_NAME: "Yaml Defined Name",
**TESTDATA,
}
@respx.mock
@pytest.mark.usefixtures("fakeimg_png")
@@ -135,11 +135,9 @@ async def test_form_only_stillimage(
mock_setup_entry: _patch[MagicMock],
) -> None:
"""Test we complete ok if the user wants still images only."""
data = TESTDATA.copy()
data.pop(CONF_STREAM_SOURCE)
result1 = await hass.config_entries.flow.async_configure(
user_flow["flow_id"],
data,
TESTDATA_ONLYSTILL,
)
await hass.async_block_till_done()
assert result1["type"] is FlowResultType.FORM
@@ -235,11 +233,9 @@ async def test_form_only_stillimage_gif(
mock_setup_entry: _patch[MagicMock],
) -> None:
"""Test we complete ok if the user wants a gif."""
data = TESTDATA.copy()
data.pop(CONF_STREAM_SOURCE)
result1 = await hass.config_entries.flow.async_configure(
user_flow["flow_id"],
data,
TESTDATA_ONLYSTILL,
)
assert result1["type"] is FlowResultType.FORM
assert result1["step_id"] == "user_confirm"
@@ -262,11 +258,9 @@ async def test_form_only_svg_whitespace(
"""Test we complete ok if svg starts with whitespace, issue #68889."""
fakeimgbytes_wspace_svg = bytes(" \n ", encoding="utf-8") + fakeimgbytes_svg
respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_wspace_svg)
data = TESTDATA.copy()
data.pop(CONF_STREAM_SOURCE)
result1 = await hass.config_entries.flow.async_configure(
user_flow["flow_id"],
data,
TESTDATA_ONLYSTILL,
)
assert result1["type"] is FlowResultType.FORM
assert result1["step_id"] == "user_confirm"
@@ -296,11 +290,9 @@ async def test_form_only_still_sample(
image_path = os.path.join(os.path.dirname(__file__), image_file)
image_bytes = await hass.async_add_executor_job(Path(image_path).read_bytes)
respx.get("http://127.0.0.1/testurl/1").respond(stream=image_bytes)
data = TESTDATA.copy()
data.pop(CONF_STREAM_SOURCE)
result1 = await hass.config_entries.flow.async_configure(
user_flow["flow_id"],
data,
TESTDATA_ONLYSTILL,
)
assert result1["type"] is FlowResultType.FORM
assert result1["step_id"] == "user_confirm"
@@ -364,8 +356,7 @@ async def test_form_still_template(
# There is no need to mock the request if its an
# invalid url because we will never make the request
respx.get(url).respond(stream=fakeimgbytes_png)
data = TESTDATA.copy()
data.pop(CONF_STREAM_SOURCE)
data = TESTDATA_ONLYSTILL.copy()
data[CONF_STILL_IMAGE_URL] = template
result2 = await hass.config_entries.flow.async_configure(
user_flow["flow_id"],
@@ -417,8 +408,7 @@ async def test_form_only_stream(
mock_create_stream: _patch[MagicMock],
) -> None:
"""Test we complete ok if the user wants stream only."""
data = TESTDATA.copy()
data.pop(CONF_STILL_IMAGE_URL)
data = TESTDATA_ONLYSTREAM.copy()
data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2"
result1 = await hass.config_entries.flow.async_configure(
user_flow["flow_id"],
@@ -592,8 +582,6 @@ async def test_form_stream_timeout(
@respx.mock
async def test_form_stream_not_set_up(hass: HomeAssistant, user_flow) -> None:
"""Test we handle if stream has not been set up."""
TESTDATA_ONLY_STREAM = TESTDATA.copy()
TESTDATA_ONLY_STREAM.pop(CONF_STILL_IMAGE_URL)
with patch(
"homeassistant.components.generic.config_flow.create_stream",
@@ -601,7 +589,7 @@ async def test_form_stream_not_set_up(hass: HomeAssistant, user_flow) -> None:
):
result1 = await hass.config_entries.flow.async_configure(
user_flow["flow_id"],
TESTDATA_ONLY_STREAM,
TESTDATA_ONLYSTREAM,
)
await hass.async_block_till_done()
@@ -612,8 +600,6 @@ async def test_form_stream_not_set_up(hass: HomeAssistant, user_flow) -> None:
@respx.mock
async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None:
"""Test the unknown error for streams."""
TESTDATA_ONLY_STREAM = TESTDATA.copy()
TESTDATA_ONLY_STREAM.pop(CONF_STILL_IMAGE_URL)
with (
patch(
@@ -624,7 +610,7 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None:
):
await hass.config_entries.flow.async_configure(
user_flow["flow_id"],
TESTDATA_ONLY_STREAM,
TESTDATA_ONLYSTREAM,
)
await hass.async_block_till_done()
@@ -794,14 +780,12 @@ async def test_options_only_stream(
mock_create_stream: _patch[MagicMock],
) -> None:
"""Test the options flow without a still_image_url."""
data = TESTDATA.copy()
data.pop(CONF_STILL_IMAGE_URL)
mock_entry = MockConfigEntry(
title="Test Camera",
domain=DOMAIN,
data={},
options=data,
options=TESTDATA_ONLYSTREAM,
)
mock_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_entry.entry_id)
@@ -813,7 +797,7 @@ async def test_options_only_stream(
# try updating the config options
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=data,
user_input=TESTDATA_ONLYSTREAM,
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "user_confirm"
@@ -830,7 +814,8 @@ async def test_options_still_and_stream_not_provided(
mock_setup_entry: _patch[MagicMock],
) -> None:
"""Test we show a suitable error if neither still or stream URL are provided."""
data = TESTDATA.copy()
data = TESTDATA_ONLYSTILL.copy()
data.pop(CONF_STILL_IMAGE_URL)
mock_entry = MockConfigEntry(
title="Test Camera",
@@ -845,8 +830,6 @@ async def test_options_still_and_stream_not_provided(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
data.pop(CONF_STILL_IMAGE_URL)
data.pop(CONF_STREAM_SOURCE)
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=data,

View File

@@ -12599,7 +12599,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_1:device-registry]
@@ -12690,7 +12690,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_2:device-registry]
@@ -12781,7 +12781,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_3:device-registry]
@@ -12872,7 +12872,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_4:device-registry]
@@ -12963,7 +12963,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import:device-registry]
@@ -13054,7 +13054,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_1:device-registry]
@@ -13145,7 +13145,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_2:device-registry]
@@ -13236,7 +13236,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_3:device-registry]
@@ -13327,7 +13327,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_4:device-registry]
@@ -13418,7 +13418,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_frequency:device-registry]
@@ -15249,7 +15249,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unavailable',
})
# ---
# name: test_sensors[HWE-SKT-11-entity_ids2][sensor.device_energy_import:device-registry]

View File

@@ -19,9 +19,9 @@
}),
'labels': set({
}),
'manufacturer': 'Husqvarna',
'model': 'AUTOMOWER® 450XH',
'model_id': None,
'manufacturer': 'HUSQVARNA',
'model': 'AUTOMOWER®',
'model_id': '450XH',
'name': 'Test Mower 1',
'name_by_user': None,
'primary_config_entry': <ANY>,

View File

@@ -8,7 +8,10 @@ from improv_ble_client import Error, State, errors as improv_ble_errors
import pytest
from homeassistant import config_entries
from homeassistant.components.bluetooth import BluetoothChange
from homeassistant.components.bluetooth import (
BluetoothChange,
BluetoothServiceInfoBleak,
)
from homeassistant.components.improv_ble.const import DOMAIN
from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import CONF_ADDRESS
@@ -22,7 +25,8 @@ from . import (
PROVISIONED_IMPROV_BLE_DISCOVERY_INFO,
)
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_capture_events
from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
IMPROV_BLE = "homeassistant.components.improv_ble"
@@ -696,3 +700,62 @@ async def test_provision_fails_invalid_data(
"Received invalid improv via BLE data '000000000000' from device with bluetooth address 'AA:BB:CC:DD:EE:F0'"
in caplog.text
)
async def test_bluetooth_name_update(hass: HomeAssistant) -> None:
"""Test that discovery notification title updates when device name changes."""
with patch(
f"{IMPROV_BLE}.config_flow.bluetooth.async_register_callback",
) as mock_async_register_callback:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=IMPROV_BLE_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
# Get the flow to check initial title_placeholders
flow = hass.config_entries.flow.async_get(result["flow_id"])
assert flow["context"]["title_placeholders"] == {"name": "00123456"}
# Get the callback that was registered
callback = mock_async_register_callback.call_args.args[1]
# Create updated discovery info with a new name
updated_discovery_info = BluetoothServiceInfoBleak(
name="improvtest",
address="AA:BB:CC:DD:EE:F0",
rssi=-60,
manufacturer_data={},
service_uuids=[IMPROV_BLE_DISCOVERY_INFO.service_uuids[0]],
service_data=IMPROV_BLE_DISCOVERY_INFO.service_data,
source="local",
device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="improvtest"),
advertisement=generate_advertisement_data(
service_uuids=IMPROV_BLE_DISCOVERY_INFO.service_uuids,
service_data=IMPROV_BLE_DISCOVERY_INFO.service_data,
),
time=0,
connectable=True,
tx_power=-127,
)
# Capture events to verify frontend notification
events = async_capture_events(hass, "data_entry_flow_progressed")
# Simulate receiving updated advertisement with new name
callback(updated_discovery_info, BluetoothChange.ADVERTISEMENT)
await hass.async_block_till_done()
# Verify title_placeholders were updated
flow = hass.config_entries.flow.async_get(result["flow_id"])
assert flow["context"]["title_placeholders"] == {"name": "improvtest"}
# Verify frontend was notified
assert len(events) == 1
assert events[0].data == {
"handler": DOMAIN,
"flow_id": result["flow_id"],
"refresh": True,
}

View File

@@ -213,12 +213,14 @@ async def test_generate_data_with_attachments(
@pytest.mark.usefixtures("mock_init_component")
@freeze_time("2025-06-14 22:59:00")
@pytest.mark.parametrize("image_model", ["gpt-image-1", "gpt-image-1-mini"])
async def test_generate_image(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
image_model: str,
) -> None:
"""Test AI Task image generation."""
entity_id = "ai_task.openai_ai_task"
@@ -232,6 +234,12 @@ async def test_generate_image(
if entry.subentry_type == "ai_task_data"
)
)
hass.config_entries.async_update_subentry(
mock_config_entry,
ai_task_entry,
data={"image_model": image_model},
)
await hass.async_block_till_done()
assert entity_entry is not None
assert entity_entry.config_entry_id == mock_config_entry.entry_id
assert entity_entry.config_subentry_id == ai_task_entry.subentry_id
@@ -258,7 +266,7 @@ async def test_generate_image(
assert result["width"] == 1536
assert result["revised_prompt"] == "Mock revised prompt."
assert result["mime_type"] == "image/png"
assert result["model"] == "gpt-image-1"
assert result["model"] == image_model
mock_upload_media.assert_called_once()
image_data = mock_upload_media.call_args[0][1]

View File

@@ -14,6 +14,7 @@ from homeassistant.components.openai_conversation.config_flow import (
from homeassistant.components.openai_conversation.const import (
CONF_CHAT_MODEL,
CONF_CODE_INTERPRETER,
CONF_IMAGE_MODEL,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_REASONING_EFFORT,
@@ -917,6 +918,7 @@ async def test_creating_ai_task_subentry_advanced(
assert result4.get("data") == {
CONF_RECOMMENDED: False,
CONF_CHAT_MODEL: "gpt-4o",
CONF_IMAGE_MODEL: "gpt-image-1",
CONF_MAX_TOKENS: 200,
CONF_TEMPERATURE: 0.5,
CONF_TOP_P: 0.9,

View File

@@ -1,7 +1,7 @@
"""Test fixtures for Prowl."""
from collections.abc import Generator
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, patch
import pytest
@@ -27,7 +27,7 @@ BAD_API_RESPONSE = {"base": "bad_api_response"}
@pytest.fixture
async def configure_prowl_through_yaml(
hass: HomeAssistant, mock_prowlpy: Generator[Mock]
hass: HomeAssistant, mock_prowlpy: Generator[AsyncMock]
) -> Generator[None]:
"""Configure the notify domain with YAML for the Prowl platform."""
await async_setup_component(
@@ -48,7 +48,9 @@ async def configure_prowl_through_yaml(
@pytest.fixture
async def prowl_notification_entity(
hass: HomeAssistant, mock_prowlpy: Mock, mock_prowlpy_config_entry: MockConfigEntry
hass: HomeAssistant,
mock_prowlpy: AsyncMock,
mock_prowlpy_config_entry: MockConfigEntry,
) -> Generator[MockConfigEntry]:
"""Configure a Prowl Notification Entity."""
mock_prowlpy.verify_key.return_value = True
@@ -61,11 +63,24 @@ async def prowl_notification_entity(
@pytest.fixture
def mock_prowlpy() -> Generator[Mock]:
def mock_prowlpy() -> Generator[AsyncMock]:
"""Mock the prowlpy library."""
mock_instance = AsyncMock()
with patch("homeassistant.components.prowl.notify.prowlpy.Prowl") as MockProwl:
mock_instance = MockProwl.return_value
with (
patch(
"homeassistant.components.prowl.notify.prowlpy.AsyncProwl",
return_value=mock_instance,
),
patch(
"homeassistant.components.prowl.helpers.prowlpy.AsyncProwl",
return_value=mock_instance,
),
patch(
"homeassistant.components.prowl.__init__.prowlpy.AsyncProwl",
return_value=mock_instance,
),
):
yield mock_instance

View File

@@ -1,6 +1,6 @@
"""Test Prowl config flow."""
from unittest.mock import Mock
from unittest.mock import AsyncMock
import prowlpy
@@ -13,7 +13,7 @@ from homeassistant.data_entry_flow import FlowResultType
from .conftest import BAD_API_RESPONSE, CONF_INPUT, INVALID_API_KEY_ERROR, TIMEOUT_ERROR
async def test_flow_user(hass: HomeAssistant, mock_prowlpy: Mock) -> None:
async def test_flow_user(hass: HomeAssistant, mock_prowlpy: AsyncMock) -> None:
"""Test user initialized flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -30,7 +30,9 @@ async def test_flow_user(hass: HomeAssistant, mock_prowlpy: Mock) -> None:
assert result["data"] == {CONF_API_KEY: CONF_INPUT[CONF_API_KEY]}
async def test_flow_duplicate_api_key(hass: HomeAssistant, mock_prowlpy: Mock) -> None:
async def test_flow_duplicate_api_key(
hass: HomeAssistant, mock_prowlpy: AsyncMock
) -> None:
"""Test user initialized flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -52,7 +54,7 @@ async def test_flow_duplicate_api_key(hass: HomeAssistant, mock_prowlpy: Mock) -
assert result["type"] is FlowResultType.ABORT
async def test_flow_user_bad_key(hass: HomeAssistant, mock_prowlpy: Mock) -> None:
async def test_flow_user_bad_key(hass: HomeAssistant, mock_prowlpy: AsyncMock) -> None:
"""Test user submitting a bad API key."""
mock_prowlpy.verify_key.side_effect = prowlpy.APIError("Invalid API key")
@@ -70,7 +72,9 @@ async def test_flow_user_bad_key(hass: HomeAssistant, mock_prowlpy: Mock) -> Non
assert result["errors"] == INVALID_API_KEY_ERROR
async def test_flow_user_prowl_timeout(hass: HomeAssistant, mock_prowlpy: Mock) -> None:
async def test_flow_user_prowl_timeout(
hass: HomeAssistant, mock_prowlpy: AsyncMock
) -> None:
"""Test Prowl API timeout."""
mock_prowlpy.verify_key.side_effect = TimeoutError
@@ -88,7 +92,7 @@ async def test_flow_user_prowl_timeout(hass: HomeAssistant, mock_prowlpy: Mock)
assert result["errors"] == TIMEOUT_ERROR
async def test_flow_api_failure(hass: HomeAssistant, mock_prowlpy: Mock) -> None:
async def test_flow_api_failure(hass: HomeAssistant, mock_prowlpy: AsyncMock) -> None:
"""Test Prowl API failure."""
mock_prowlpy.verify_key.side_effect = prowlpy.APIError(BAD_API_RESPONSE)

View File

@@ -1,6 +1,6 @@
"""Testing the Prowl initialisation."""
from unittest.mock import Mock
from unittest.mock import AsyncMock
import prowlpy
import pytest
@@ -18,7 +18,7 @@ from tests.common import MockConfigEntry
async def test_load_reload_unload_config_entry(
hass: HomeAssistant,
mock_prowlpy_config_entry: MockConfigEntry,
mock_prowlpy: Mock,
mock_prowlpy: AsyncMock,
) -> None:
"""Test the Prowl configuration entry loading/reloading/unloading."""
mock_prowlpy_config_entry.add_to_hass(hass)
@@ -57,7 +57,7 @@ async def test_load_reload_unload_config_entry(
async def test_config_entry_failures(
hass: HomeAssistant,
mock_prowlpy_config_entry: MockConfigEntry,
mock_prowlpy: Mock,
mock_prowlpy: AsyncMock,
prowlpy_side_effect,
expected_config_state: ConfigEntryState,
) -> None:

View File

@@ -1,7 +1,7 @@
"""Test the Prowl notifications."""
from typing import Any
from unittest.mock import Mock
from unittest.mock import AsyncMock
import prowlpy
import pytest
@@ -29,7 +29,7 @@ EXPECTED_SEND_PARAMETERS = {
@pytest.mark.usefixtures("configure_prowl_through_yaml")
async def test_send_notification_service(
hass: HomeAssistant,
mock_prowlpy: Mock,
mock_prowlpy: AsyncMock,
) -> None:
"""Set up Prowl, call notify service, and check API call."""
assert hass.services.has_service(notify.DOMAIN, DOMAIN)
@@ -40,12 +40,12 @@ async def test_send_notification_service(
blocking=True,
)
mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS)
mock_prowlpy.post.assert_called_once_with(**EXPECTED_SEND_PARAMETERS)
async def test_send_notification_entity_service(
hass: HomeAssistant,
mock_prowlpy: Mock,
mock_prowlpy: AsyncMock,
mock_prowlpy_config_entry: MockConfigEntry,
) -> None:
"""Set up Prowl via config entry, call notify service, and check API call."""
@@ -65,7 +65,7 @@ async def test_send_notification_entity_service(
blocking=True,
)
mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS)
mock_prowlpy.post.assert_called_once_with(**EXPECTED_SEND_PARAMETERS)
@pytest.mark.parametrize(
@@ -102,7 +102,7 @@ async def test_send_notification_entity_service(
)
async def test_fail_send_notification_entity_service(
hass: HomeAssistant,
mock_prowlpy: Mock,
mock_prowlpy: AsyncMock,
mock_prowlpy_config_entry: MockConfigEntry,
prowlpy_side_effect: Exception,
raised_exception: type[Exception],
@@ -113,7 +113,7 @@ async def test_fail_send_notification_entity_service(
await hass.config_entries.async_setup(mock_prowlpy_config_entry.entry_id)
await hass.async_block_till_done()
mock_prowlpy.send.side_effect = prowlpy_side_effect
mock_prowlpy.post.side_effect = prowlpy_side_effect
assert hass.services.has_service(notify.DOMAIN, notify.SERVICE_SEND_MESSAGE)
with pytest.raises(raised_exception, match=exception_message):
@@ -128,7 +128,7 @@ async def test_fail_send_notification_entity_service(
blocking=True,
)
mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS)
mock_prowlpy.post.assert_called_once_with(**EXPECTED_SEND_PARAMETERS)
@pytest.mark.parametrize(
@@ -166,13 +166,13 @@ async def test_fail_send_notification_entity_service(
@pytest.mark.usefixtures("configure_prowl_through_yaml")
async def test_fail_send_notification(
hass: HomeAssistant,
mock_prowlpy: Mock,
mock_prowlpy: AsyncMock,
prowlpy_side_effect: Exception,
raised_exception: type[Exception],
exception_message: str | None,
) -> None:
"""Sending a message via Prowl with a failure."""
mock_prowlpy.send.side_effect = prowlpy_side_effect
mock_prowlpy.post.side_effect = prowlpy_side_effect
assert hass.services.has_service(notify.DOMAIN, DOMAIN)
with pytest.raises(raised_exception, match=exception_message):
@@ -183,7 +183,7 @@ async def test_fail_send_notification(
blocking=True,
)
mock_prowlpy.send.assert_called_once_with(**EXPECTED_SEND_PARAMETERS)
mock_prowlpy.post.assert_called_once_with(**EXPECTED_SEND_PARAMETERS)
@pytest.mark.parametrize(
@@ -204,12 +204,12 @@ async def test_fail_send_notification(
@pytest.mark.usefixtures("configure_prowl_through_yaml")
async def test_other_exception_send_notification(
hass: HomeAssistant,
mock_prowlpy: Mock,
mock_prowlpy: AsyncMock,
service_data: dict[str, Any],
expected_send_parameters: dict[str, Any],
) -> None:
"""Sending a message via Prowl with a general unhandled exception."""
mock_prowlpy.send.side_effect = SyntaxError
mock_prowlpy.post.side_effect = SyntaxError
assert hass.services.has_service(notify.DOMAIN, DOMAIN)
with pytest.raises(SyntaxError):
@@ -220,4 +220,4 @@ async def test_other_exception_send_notification(
blocking=True,
)
mock_prowlpy.send.assert_called_once_with(**expected_send_parameters)
mock_prowlpy.post.assert_called_once_with(**expected_send_parameters)

View File

@@ -324,3 +324,32 @@ async def test_zeroconf_discovery_already_configured(
assert result.get("type") is FlowResultType.ABORT
assert entry.unique_id == "test_zeroconf_name._wyoming._tcp.local._Test Satellite"
async def test_bad_config_entry(hass: HomeAssistant) -> None:
"""Test we can continue if a config entry is missing info."""
entry = MockConfigEntry(
domain=DOMAIN,
data={}, # no host/port
)
entry.add_to_hass(hass)
# hassio
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=ADDON_DISCOVERY,
context={"source": config_entries.SOURCE_HASSIO},
)
assert result.get("type") is FlowResultType.FORM
# zeroconf
with patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
return_value=SATELLITE_INFO,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=ZEROCONF_DISCOVERY,
context={"source": config_entries.SOURCE_ZEROCONF},
)
assert result.get("type") is FlowResultType.FORM

View File

@@ -1,6 +1,6 @@
"""The tests for the yale platform."""
from unittest.mock import Mock
from unittest.mock import Mock, patch
from aiohttp import ClientResponseError
import pytest
@@ -28,6 +28,8 @@ from .mocks import (
_mock_inoperative_yale_lock_detail,
_mock_lock_with_offline_key,
_mock_operative_yale_lock_detail,
mock_client_credentials,
mock_yale_config_entry,
)
from tests.typing import WebSocketGenerator
@@ -234,3 +236,18 @@ async def test_device_remove_devices(
)
response = await client.remove_device(dead_device_entry.id, config_entry.entry_id)
assert response["success"]
async def test_oauth_implementation_not_available(hass: HomeAssistant) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
await mock_client_credentials(hass)
entry = await mock_yale_config_entry(hass)
with patch(
"homeassistant.components.yale.config_entry_oauth2_flow.async_get_config_entry_implementation",
side_effect=ValueError("Implementation not available"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -9434,3 +9434,74 @@ async def test_create_entry_existing_unique_id(
"working in Home Assistant 2026.3, please create a bug report at https:"
)
assert (log_text in caplog.text) == expected_log
async def test_async_update_title_placeholders(hass: HomeAssistant) -> None:
"""Test async_update_title_placeholders updates context and notifies listeners."""
class TestFlow(config_entries.ConfigFlow):
"""Test flow."""
VERSION = 1
async def async_step_user(self, user_input=None):
"""Test user step."""
self.context["title_placeholders"] = {"initial": "value"}
return self.async_show_form(step_id="user")
mock_integration(hass, MockModule("comp"))
mock_platform(hass, "comp.config_flow", None)
with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}):
result = await hass.config_entries.flow.async_init(
"comp", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
# Get the flow to check initial title_placeholders
flow = hass.config_entries.flow.async_get(result["flow_id"])
assert flow["context"]["title_placeholders"] == {"initial": "value"}
# Get the flow instance to call methods
flow_instance = hass.config_entries.flow._progress[result["flow_id"]]
# Capture events to verify frontend notification
events = async_capture_events(
hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED
)
# Update title placeholders
flow_instance.async_update_title_placeholders({"name": "updated"})
await hass.async_block_till_done()
# Verify placeholders were updated (preserving existing values)
flow = hass.config_entries.flow.async_get(result["flow_id"])
assert flow["context"]["title_placeholders"] == {
"initial": "value",
"name": "updated",
}
# Verify frontend was notified
assert len(events) == 1
assert events[0].data == {
"handler": "comp",
"flow_id": result["flow_id"],
"refresh": True,
}
# Update again with overlapping key
flow_instance.async_update_title_placeholders(
{"initial": "new_value", "another": "key"}
)
await hass.async_block_till_done()
# Verify placeholders were updated correctly
flow = hass.config_entries.flow.async_get(result["flow_id"])
assert flow["context"]["title_placeholders"] == {
"initial": "new_value",
"name": "updated",
"another": "key",
}
# Verify frontend was notified again
assert len(events) == 2