Compare commits

...

57 Commits

Author SHA1 Message Date
Franck Nijhof 35c90d9bde Bump version to 2025.5.0b8 2025-05-07 07:38:18 +00:00
Raphael Hehl a9632bd0ff Bump uiprotect to version 7.6.0 (#144369) 2025-05-07 07:38:12 +00:00
epenet 983e134ae9 Bump renault-api to 0.3.1 (#144366)
* Bump renault-api to 0.3.1

* Adjust tests
2025-05-07 07:38:10 +00:00
Jan Bouwhuis e217532f9e Fix field validation for mqtt subentry options in sections (#144355) 2025-05-07 07:38:09 +00:00
Franck Nijhof 1eeab28eec Bump version to 2025.5.0b7 2025-05-06 19:30:08 +00:00
Bram Kragten 2a3bd45901 Update frontend to 20250506.0 (#144354) 2025-05-06 19:29:59 +00:00
Paulus Schoutsen d16453a465 Remove some media player intent checks for when paused (#144351) 2025-05-06 19:29:59 +00:00
Jan Bouwhuis de63dddc96 Ensure all default MQTT subentry option values are saved (#144347)
* Ensure all default MQTT subentry option values are saved

* Apply correct filter
2025-05-06 19:29:58 +00:00
J. Nick Koston ccffe19611 Bump bluemaestro-ble to 0.4.1 (#144345)
changelog: https://github.com/Bluetooth-Devices/bluemaestro-ble/compare/v0.4.0...v0.4.1

fixes #https://github.com/home-assistant/core/issues/144339
2025-05-06 19:29:57 +00:00
Martin Hjelmare 806bcf47d9 Fix Z-Wave migration flow to unload config entry before unplugging controller (#144343)
* Fix Z-Wave migration unload config entry before unplugging controller

* Remove typo
2025-05-06 19:29:56 +00:00
Franck Nijhof 5ed3f18d70 Bump version to 2025.5.0b6 2025-05-06 13:57:05 +00:00
Martin Hjelmare 7cc142dd59 Fix Z-Wave to reload config entry after migration nvm restore (#144338) 2025-05-06 13:56:58 +00:00
Robert Resch 9150c78901 Add endpoint validation for AWS S3 (#144334) 2025-05-06 13:56:56 +00:00
Stefan Agner 4b7c337dc9 Update Home Assistant base image to 2025.05.0 (#144333) 2025-05-06 13:56:55 +00:00
Robert Resch 1aa79c71cc Rename S3 to AWS_S3 (#144324) 2025-05-06 13:56:54 +00:00
Robert Resch 5f70140e72 Revert "Disable S3 checksums" (#144092) (#144318) 2025-05-06 13:56:52 +00:00
Martin Hjelmare 58f7a8a51e Fix Z-Wave USB discovery to use serial by id path (#144314) 2025-05-06 13:56:51 +00:00
Ivan Lopez Hernandez a91ae71139 Fixes #140182 by checking file status before sending the prompt. (#144131)
* Added unit tests

* Addressed review comments

* Fixed tests

* PR comments
2025-05-06 13:56:50 +00:00
Cerallin 576b4ef60d Bump xiaomi-ble to 0.38.0 (#143885) 2025-05-06 13:56:48 +00:00
Paulus Schoutsen 918499a85c Bump version to 2025.5.0b5 2025-05-06 02:54:14 +00:00
Jamin 46ef578986 Bump VoIP utils to 0.3.2 (#144298) 2025-05-06 02:54:01 +00:00
Pete Sage 86162eb660 Rehlko adjust timeouts for coordinator polls (#144297) 2025-05-06 02:54:00 +00:00
Jan Bouwhuis 7f7a33b027 Fix mqtt subentry device name is not required but should be (#144289)
Fix mqtt subentry device name is not required
2025-05-06 02:53:59 +00:00
Michael 867df99353 Fix un-/re-load of Feedreader integration (#144285)
fix unload platforms call
2025-05-06 02:53:58 +00:00
Martin Hjelmare 283e9d073b Fix Z-Wave config flow forms (#144279) 2025-05-06 02:53:57 +00:00
Jan Bouwhuis 38f26376a1 Fix default entity name not the device default entity when no name set on MQTT subentry entity (#144263) 2025-05-06 02:53:56 +00:00
Jamin 0322dd0e0f Improve Voip pipeline stability (#137620)
* Improve Voip pipeline stability

It appears the pipeline is being unexpectedly cancelled in some
instances. In order to mitigate this issue hang ups will be detected
using a separate task rather than relying on timeouts in the STT read
method. Also reading STT events will be retried once if it is cancelled.
The pipeline will also catch and log any CancelledErrors to help with
further debugging.

* Update Voip tests

* Remove unnecessary changes

Remove unnecessary logging and cancelled error handling in wyoming STT.

* Remove comment about clearing system prompt

The test no longer checks for clearing the system prompt. Since that
logic exists completely in the assist_satellite component I think it is
reasonable to only test that logic in the unit tests for that component.

* Re-raise cancellation

Re-raise CancelledError if the current task is cancelling in the check hangup task

Co-authored-by: J. Nick Koston <nick@koston.org>

* Re-raise CancelledError in pipeline as well

* Fix formatting issue

* Remove unnecessary logging

* Add MockResultStream import to tests

This was presumably missed while merging

* Cancel check hangup task on disconnect

* Add myself as codeowner for VoIP

* Update CODEOWNERS

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-05-06 02:53:55 +00:00
Paulus Schoutsen 3798802557 Bump version to 2025.5.0b4 2025-05-05 14:51:20 -04:00
Paul Bottein f7833bdbd4 Update frontend to 20250502.1 (#144276) 2025-05-05 14:51:14 -04:00
Josef Zweck e3a156c9b7 Bump pylamarzocco to 2.0.0 (#144275) 2025-05-05 14:51:13 -04:00
Luke Lashley 6247ec73a3 Bump Roborock Map Parser to 0.1.4 (#144260)
Bump to 0.1.4
2025-05-05 14:51:12 -04:00
Luke Lashley 3feda06e60 Bump python-roborock to 2.18.2 (#144235) 2025-05-05 14:51:11 -04:00
Åke Strandberg 56e895fdd4 Remove program phase sensor from miele vacuum robot (#144257)
* Use device class transation

* Remove program pghses sensor from robot vacuum cleaner
2025-05-05 14:49:10 -04:00
tronikos 541506cbdb Fix Invalid statistic_id for Opower: National Grid (#144243)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2025-05-05 14:49:09 -04:00
Allen Porter 1f4cda6282 Bump ical to 9.2.0 (#144240) 2025-05-05 14:49:08 -04:00
Allen Porter 6f77d0b0d5 Update local calendar to process calendar events in the executor (#144233) 2025-05-05 14:49:07 -04:00
Allen Porter 7976e1b104 Update remote calendar to do all event handling in an executor (#144232) 2025-05-05 14:49:05 -04:00
Eliz 1c260cfb00 Fix missing head forwarding in ingress (#144231)
* Add support for connect, head and trace in ingress

* added tests

* update the testutil

* fix

* fix empty space

* removed connect

* remove trace
2025-05-05 14:49:04 -04:00
Allen Porter 8424f179e4 Fix Office 365 calendars to be compatible with rfc5545 (#144230) 2025-05-05 14:49:03 -04:00
Pete Sage 00a14a0824 bump aiokem to 0.5.10 (#144203) 2025-05-05 14:49:02 -04:00
Pete Sage 34bec1c50f Avoid delaying HA startup in Rehlko (#144202) 2025-05-05 14:49:01 -04:00
tronikos 1d0c520f64 Use names instead of statistic IDs in the Opower repair issue (#144018)
* Use names instead of statistic IDs in the Opower repair issue

* target_ids
2025-05-05 14:49:00 -04:00
Luca De Petrillo d51eda40b3 Fix message corruption in picotts component (#141182) 2025-05-05 14:48:59 -04:00
Paulus Schoutsen 2d3259413a Bump version to 2025.5.0b3 2025-05-04 16:11:29 +00:00
Paulus Schoutsen 7a7bd9c621 Fix intent TurnOn creating stack trace for buttons (#144205) 2025-05-04 16:11:22 +00:00
Maciej Bieniek 8ce0b6b4b3 Add missing pollen category to AccuWeather (#144185)
* Add extreme level to pollen map

* Sort

* Sort
2025-05-04 16:11:21 +00:00
hahn-th 63679333cc Bump homematicip to 2.0.1.1 (#144182)
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-05-04 16:11:20 +00:00
Marc Mueller 5b12bdca00 Fix licenses check for setuptools (#144181) 2025-05-04 16:11:18 +00:00
Åke Strandberg 99e13278e3 Bump pymiele to 0.4.3 (#144176)
* Use device class transation

* Bump pymiele to 0.4.3

---------

Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-05-04 16:11:17 +00:00
Paulus Schoutsen 07a03ee10d Point thumbnail TTS media source to right logo (#144162) 2025-05-04 16:11:16 +00:00
J. Nick Koston fb9f8e3581 Bump zeroconf to 0.147.0 (#144158) 2025-05-04 16:11:15 +00:00
J. Nick Koston ee125cd9a4 Bump habluetooth to 3.48.2 (#144157) 2025-05-04 16:11:14 +00:00
Josef Zweck 35a1429e2b Switch to common clientsession for lamarzocco (#144137) 2025-05-04 16:11:13 +00:00
J. Nick Koston 89916b38e9 Add tests to ensure ESPHome entity_ids are preserved on upgrade (#144116) 2025-05-04 16:11:10 +00:00
tronikos c560439545 Skip the update right after the migration in Opower (#144088)
* Wait for the migration to finish in Opower

* Don't call async_block_till_done since this can timeout and seems to meant for tests

* Don't call async_block_till_done since this can timeout and seems to meant for tests
2025-05-04 16:11:10 +00:00
Charlie Rusbridger 7322be2006 Use kodi posters, fall back to thumbnails if unavailable. (#144066) 2025-05-04 16:11:08 +00:00
Florian Sabonchi e95ed12ba1 Fix check for locked device in AVM Fritz!SmartHome (#141697)
* feat: raise execption on hvac mode while device is locked

* fix: test for setting hvac mode while device is locked.

* feat: update translation

* feat: add separate translations for HVAC and temperature

* fix: test cases

* fix: test cases for test_set_preset_mode_boost

* rev: code review

* rev: exception string

* feat: updated  error message and added helper function

* Update homeassistant/components/fritzbox/strings.json

Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>

* fix: translation key

* remove check_active_or_lock_mode from async_set_preset_mode

---------

Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>
2025-05-04 16:11:07 +00:00
92 changed files with 1677 additions and 1229 deletions
Generated
+4 -4
View File
@@ -171,6 +171,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @danielsjf
/tests/components/awair/ @ahayworth @danielsjf
/homeassistant/components/aws_s3/ @tomasbedrich
/tests/components/aws_s3/ @tomasbedrich
/homeassistant/components/axis/ @Kane610
/tests/components/axis/ @Kane610
/homeassistant/components/azure_data_explorer/ @kaareseras
@@ -1318,8 +1320,6 @@ build.json @home-assistant/supervisor
/tests/components/ruuvitag_ble/ @akx
/homeassistant/components/rympro/ @OnFreund @elad-bar @maorcc
/tests/components/rympro/ @OnFreund @elad-bar @maorcc
/homeassistant/components/s3/ @tomasbedrich
/tests/components/s3/ @tomasbedrich
/homeassistant/components/sabnzbd/ @shaiu @jpbede
/tests/components/sabnzbd/ @shaiu @jpbede
/homeassistant/components/saj/ @fredericvl
@@ -1678,8 +1678,8 @@ build.json @home-assistant/supervisor
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare
/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74
/tests/components/vodafone_station/ @paoloantinori @chemelli74
/homeassistant/components/voip/ @balloob @synesthesiam
/tests/components/voip/ @balloob @synesthesiam
/homeassistant/components/voip/ @balloob @synesthesiam @jaminh
/tests/components/voip/ @balloob @synesthesiam @jaminh
/homeassistant/components/volumio/ @OnFreund
/tests/components/volumio/ @OnFreund
/homeassistant/components/volvooncall/ @molobrakos
+5 -5
View File
@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.02.1
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.02.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.02.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.02.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.02.1
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.05.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.0
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
+8 -1
View File
@@ -1,5 +1,12 @@
{
"domain": "amazon",
"name": "Amazon",
"integrations": ["alexa", "amazon_polly", "aws", "fire_tv", "route53"]
"integrations": [
"alexa",
"amazon_polly",
"aws",
"aws_s3",
"fire_tv",
"route53"
]
}
@@ -67,6 +67,7 @@ POLLEN_CATEGORY_MAP = {
2: "moderate",
3: "high",
4: "very_high",
5: "extreme",
}
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
@@ -72,6 +72,7 @@
"level": {
"name": "Level",
"state": {
"extreme": "Extreme",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "Moderate",
@@ -89,6 +90,7 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
@@ -123,6 +125,7 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
@@ -167,6 +170,7 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
@@ -181,6 +185,7 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
@@ -195,6 +200,7 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
@@ -1,4 +1,4 @@
"""The S3 integration."""
"""The AWS S3 integration."""
from __future__ import annotations
@@ -7,7 +7,6 @@ from typing import cast
from aiobotocore.client import AioBaseClient as S3Client
from aiobotocore.session import AioSession
from botocore.config import Config
from botocore.exceptions import ClientError, ConnectionError, ParamValidationError
from homeassistant.config_entries import ConfigEntry
@@ -33,11 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
"""Set up S3 from a config entry."""
data = cast(dict, entry.data)
# due to https://github.com/home-assistant/core/issues/143995
config = Config(
request_checksum_calculation="when_required",
response_checksum_validation="when_required",
)
try:
session = AioSession()
# pylint: disable-next=unnecessary-dunder-call
@@ -46,7 +40,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
endpoint_url=data.get(CONF_ENDPOINT_URL),
aws_secret_access_key=data[CONF_SECRET_ACCESS_KEY],
aws_access_key_id=data[CONF_ACCESS_KEY_ID],
config=config,
).__aenter__()
await client.head_bucket(Bucket=data[CONF_BUCKET])
except ClientError as err:
@@ -1,4 +1,4 @@
"""Backup platform for the S3 integration."""
"""Backup platform for the AWS S3 integration."""
from collections.abc import AsyncIterator, Callable, Coroutine
import functools
@@ -1,8 +1,9 @@
"""Config flow for the S3 integration."""
"""Config flow for the AWS S3 integration."""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
from aiobotocore.session import AioSession
from botocore.exceptions import ClientError, ConnectionError, ParamValidationError
@@ -17,6 +18,7 @@ from homeassistant.helpers.selector import (
)
from .const import (
AWS_DOMAIN,
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_ENDPOINT_URL,
@@ -57,28 +59,34 @@ class S3ConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL],
}
)
try:
session = AioSession()
async with session.create_client(
"s3",
endpoint_url=user_input.get(CONF_ENDPOINT_URL),
aws_secret_access_key=user_input[CONF_SECRET_ACCESS_KEY],
aws_access_key_id=user_input[CONF_ACCESS_KEY_ID],
) as client:
await client.head_bucket(Bucket=user_input[CONF_BUCKET])
except ClientError:
errors["base"] = "invalid_credentials"
except ParamValidationError as err:
if "Invalid bucket name" in str(err):
errors[CONF_BUCKET] = "invalid_bucket_name"
except ValueError:
if not urlparse(user_input[CONF_ENDPOINT_URL]).hostname.endswith(
AWS_DOMAIN
):
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
except ConnectionError:
errors[CONF_ENDPOINT_URL] = "cannot_connect"
else:
return self.async_create_entry(
title=user_input[CONF_BUCKET], data=user_input
)
try:
session = AioSession()
async with session.create_client(
"s3",
endpoint_url=user_input.get(CONF_ENDPOINT_URL),
aws_secret_access_key=user_input[CONF_SECRET_ACCESS_KEY],
aws_access_key_id=user_input[CONF_ACCESS_KEY_ID],
) as client:
await client.head_bucket(Bucket=user_input[CONF_BUCKET])
except ClientError:
errors["base"] = "invalid_credentials"
except ParamValidationError as err:
if "Invalid bucket name" in str(err):
errors[CONF_BUCKET] = "invalid_bucket_name"
except ValueError:
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
except ConnectionError:
errors[CONF_ENDPOINT_URL] = "cannot_connect"
else:
return self.async_create_entry(
title=user_input[CONF_BUCKET], data=user_input
)
return self.async_show_form(
step_id="user",
@@ -1,18 +1,19 @@
"""Constants for the S3 integration."""
"""Constants for the AWS S3 integration."""
from collections.abc import Callable
from typing import Final
from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "s3"
DOMAIN: Final = "aws_s3"
CONF_ACCESS_KEY_ID = "access_key_id"
CONF_SECRET_ACCESS_KEY = "secret_access_key"
CONF_ENDPOINT_URL = "endpoint_url"
CONF_BUCKET = "bucket"
DEFAULT_ENDPOINT_URL = "https://s3.eu-central-1.amazonaws.com/"
AWS_DOMAIN = "amazonaws.com"
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
@@ -1,9 +1,9 @@
{
"domain": "s3",
"name": "S3",
"domain": "aws_s3",
"name": "AWS S3",
"codeowners": ["@tomasbedrich"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/s3",
"documentation": "https://www.home-assistant.io/integrations/aws_s3",
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["aiobotocore"],
@@ -9,19 +9,19 @@
"endpoint_url": "Endpoint URL"
},
"data_description": {
"access_key_id": "Access key ID to connect to S3 API",
"secret_access_key": "Secret access key to connect to S3 API",
"access_key_id": "Access key ID to connect to AWS S3 API",
"secret_access_key": "Secret access key to connect to AWS S3 API",
"bucket": "Bucket must already exist and be writable by the provided credentials.",
"endpoint_url": "Endpoint URL provided to [Boto3 Session]({boto3_docs_url}). Region-specific [AWS S3 endpoints]({aws_s3_docs_url}) are available in their docs."
},
"title": "Add S3 bucket"
"title": "Add AWS S3 bucket"
}
},
"error": {
"cannot_connect": "[%key:component::s3::exceptions::cannot_connect::message%]",
"invalid_bucket_name": "[%key:component::s3::exceptions::invalid_bucket_name::message%]",
"invalid_credentials": "[%key:component::s3::exceptions::invalid_credentials::message%]",
"invalid_endpoint_url": "Invalid endpoint URL"
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bluemaestro",
"iot_class": "local_push",
"requirements": ["bluemaestro-ble==0.4.0"]
"requirements": ["bluemaestro-ble==0.4.1"]
}
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.1",
"bluetooth-data-tools==1.28.1",
"dbus-fast==2.43.0",
"habluetooth==3.47.1"
"habluetooth==3.48.2"
]
}
@@ -45,7 +45,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry)
# if this is the last entry, remove the storage
if len(entries) == 1:
hass.data.pop(MY_KEY)
return await hass.config_entries.async_unload_platforms(entry, Platform.EVENT)
return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT])
async def _async_update_listener(
+17 -10
View File
@@ -144,6 +144,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
self.check_active_or_lock_mode()
if kwargs.get(ATTR_HVAC_MODE) is HVACMode.OFF:
await self.async_set_hkr_state("off")
elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None:
@@ -168,11 +169,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new operation mode."""
if self.data.holiday_active or self.data.summer_active:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="change_hvac_while_active_mode",
)
self.check_active_or_lock_mode()
if self.hvac_mode is hvac_mode:
LOGGER.debug(
"%s is already in requested hvac mode %s", self.name, hvac_mode
@@ -204,11 +201,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode."""
if self.data.holiday_active or self.data.summer_active:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="change_preset_while_active_mode",
)
self.check_active_or_lock_mode()
await self.async_set_hkr_state(PRESET_API_HKR_STATE_MAPPING[preset_mode])
@property
@@ -230,3 +223,17 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
attrs[ATTR_STATE_WINDOW_OPEN] = self.data.window_open
return attrs
def check_active_or_lock_mode(self) -> None:
"""Check if in summer/vacation mode or lock enabled."""
if self.data.holiday_active or self.data.summer_active:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="change_settings_while_active_mode",
)
if self.data.lock:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="change_settings_while_lock_enabled",
)
@@ -88,11 +88,11 @@
"manual_switching_disabled": {
"message": "Can't toggle switch while manual switching is disabled for the device."
},
"change_preset_while_active_mode": {
"message": "Can't change preset while holiday or summer mode is active on the device."
"change_settings_while_lock_enabled": {
"message": "Can't change settings while manual access for telephone, app, or user interface is disabled on the device"
},
"change_hvac_while_active_mode": {
"message": "Can't change HVAC mode while holiday or summer mode is active on the device."
"change_settings_while_active_mode": {
"message": "Can't change settings while holiday or summer mode is active on the device."
}
}
}
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250502.0"]
"requirements": ["home-assistant-frontend==20250506.0"]
}
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.1.0"]
"requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.2.0"]
}
@@ -2,11 +2,13 @@
from __future__ import annotations
import asyncio
import mimetypes
from pathlib import Path
from google.genai import Client
from google.genai.errors import APIError, ClientError
from google.genai.types import File, FileState
from requests.exceptions import Timeout
import voluptuous as vol
@@ -32,6 +34,8 @@ from .const import (
CONF_CHAT_MODEL,
CONF_PROMPT,
DOMAIN,
FILE_POLLING_INTERVAL_SECONDS,
LOGGER,
RECOMMENDED_CHAT_MODEL,
TIMEOUT_MILLIS,
)
@@ -91,8 +95,40 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
prompt_parts.append(uploaded_file)
async def wait_for_file_processing(uploaded_file: File) -> None:
"""Wait for file processing to complete."""
while True:
uploaded_file = await client.aio.files.get(
name=uploaded_file.name,
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
)
if uploaded_file.state not in (
FileState.STATE_UNSPECIFIED,
FileState.PROCESSING,
):
break
LOGGER.debug(
"Waiting for file `%s` to be processed, current state: %s",
uploaded_file.name,
uploaded_file.state,
)
await asyncio.sleep(FILE_POLLING_INTERVAL_SECONDS)
if uploaded_file.state == FileState.FAILED:
raise HomeAssistantError(
f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message}"
)
await hass.async_add_executor_job(append_files_to_prompt)
tasks = [
asyncio.create_task(wait_for_file_processing(part))
for part in prompt_parts
if isinstance(part, File) and part.state != FileState.ACTIVE
]
async with asyncio.timeout(TIMEOUT_MILLIS / 1000):
await asyncio.gather(*tasks)
try:
response = await client.aio.models.generate_content(
model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts
@@ -26,3 +26,4 @@ CONF_USE_GOOGLE_SEARCH_TOOL = "enable_google_search_tool"
RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
TIMEOUT_MILLIS = 10000
FILE_POLLING_INTERVAL_SECONDS = 0.05
@@ -109,6 +109,7 @@ class HassIOIngress(HomeAssistantView):
delete = _handle
patch = _handle
options = _handle
head = _handle
async def _handle_websocket(
self, request: web.Request, token: str, path: str
@@ -9,10 +9,10 @@ from typing import Any
from homematicip.async_home import AsyncHome
from homematicip.auth import Auth
from homematicip.base.base_connection import HmipConnectionError
from homematicip.base.enums import EventType
from homematicip.connection.connection_context import ConnectionContextBuilder
from homematicip.connection.rest_connection import RestConnection
from homematicip.exceptions.connection_exceptions import HmipConnectionError
import homeassistant
from homeassistant.config_entries import ConfigEntry
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.0.1"]
"requirements": ["homematicip==2.0.1.1"]
}
+27 -1
View File
@@ -10,6 +10,11 @@ from aiohttp import web
import voluptuous as vol
from homeassistant.components import http, sensor
from homeassistant.components.button import (
DOMAIN as BUTTON_DOMAIN,
SERVICE_PRESS as SERVICE_PRESS_BUTTON,
ButtonDeviceClass,
)
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import (
ATTR_POSITION,
@@ -20,6 +25,7 @@ from homeassistant.components.cover import (
CoverDeviceClass,
)
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN
from homeassistant.components.lock import (
DOMAIN as LOCK_DOMAIN,
SERVICE_LOCK,
@@ -80,6 +86,7 @@ __all__ = [
]
ONOFF_DEVICE_CLASSES = {
ButtonDeviceClass,
CoverDeviceClass,
ValveDeviceClass,
SwitchDeviceClass,
@@ -103,7 +110,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
intent.INTENT_TURN_ON,
HOMEASSISTANT_DOMAIN,
SERVICE_TURN_ON,
description="Turns on/opens a device or entity. For locks, this performs a 'lock' action. Use for requests like 'turn on', 'activate', 'enable', or 'lock'.",
description="Turns on/opens/presses a device or entity. For locks, this performs a 'lock' action. Use for requests like 'turn on', 'activate', 'enable', or 'lock'.",
device_classes=ONOFF_DEVICE_CLASSES,
),
)
@@ -168,6 +175,25 @@ class OnOffIntentHandler(intent.ServiceIntentHandler):
"""Call service on entity with handling for special cases."""
hass = intent_obj.hass
if state.domain in (BUTTON_DOMAIN, INPUT_BUTTON_DOMAIN):
if service != SERVICE_TURN_ON:
raise intent.IntentHandleError(
f"Entity {state.entity_id} cannot be turned off"
)
await self._run_then_background(
hass.async_create_task(
hass.services.async_call(
state.domain,
SERVICE_PRESS_BUTTON,
{ATTR_ENTITY_ID: state.entity_id},
context=intent_obj.context,
blocking=True,
)
)
)
return
if state.domain == COVER_DOMAIN:
# on = open
# off = close
+24 -10
View File
@@ -152,7 +152,10 @@ async def item_payload(item, get_thumbnail_url=None):
_LOGGER.debug("Unknown media type received: %s", media_content_type)
raise UnknownMediaType from err
thumbnail = item.get("thumbnail")
if "art" in item:
thumbnail = item["art"].get("poster", item.get("thumbnail"))
else:
thumbnail = item.get("thumbnail")
if thumbnail is not None and get_thumbnail_url is not None:
thumbnail = await get_thumbnail_url(
media_content_type, media_content_id, thumbnail_url=thumbnail
@@ -237,14 +240,16 @@ async def get_media_info(media_library, search_id, search_type):
title = None
media = None
properties = ["thumbnail"]
properties = ["thumbnail", "art"]
if search_type == MediaType.ALBUM:
if search_id:
album = await media_library.get_album_details(
album_id=int(search_id), properties=properties
)
thumbnail = media_library.thumbnail_url(
album["albumdetails"].get("thumbnail")
album["albumdetails"]["art"].get(
"poster", album["albumdetails"].get("thumbnail")
)
)
title = album["albumdetails"]["label"]
media = await media_library.get_songs(
@@ -256,6 +261,7 @@ async def get_media_info(media_library, search_id, search_type):
"album",
"thumbnail",
"track",
"art",
],
)
media = media.get("songs")
@@ -274,7 +280,9 @@ async def get_media_info(media_library, search_id, search_type):
artist_id=int(search_id), properties=properties
)
thumbnail = media_library.thumbnail_url(
artist["artistdetails"].get("thumbnail")
artist["artistdetails"]["art"].get(
"poster", artist["artistdetails"].get("thumbnail")
)
)
title = artist["artistdetails"]["label"]
else:
@@ -293,9 +301,10 @@ async def get_media_info(media_library, search_id, search_type):
movie_id=int(search_id), properties=properties
)
thumbnail = media_library.thumbnail_url(
movie["moviedetails"].get("thumbnail")
movie["moviedetails"]["art"].get(
"poster", movie["moviedetails"].get("thumbnail")
)
)
title = movie["moviedetails"]["label"]
else:
media = await media_library.get_movies(properties)
media = media.get("movies")
@@ -305,14 +314,16 @@ async def get_media_info(media_library, search_id, search_type):
if search_id:
media = await media_library.get_seasons(
tv_show_id=int(search_id),
properties=["thumbnail", "season", "tvshowid"],
properties=["thumbnail", "season", "tvshowid", "art"],
)
media = media.get("seasons")
tvshow = await media_library.get_tv_show_details(
tv_show_id=int(search_id), properties=properties
)
thumbnail = media_library.thumbnail_url(
tvshow["tvshowdetails"].get("thumbnail")
tvshow["tvshowdetails"]["art"].get(
"poster", tvshow["tvshowdetails"].get("thumbnail")
)
)
title = tvshow["tvshowdetails"]["label"]
else:
@@ -325,7 +336,7 @@ async def get_media_info(media_library, search_id, search_type):
media = await media_library.get_episodes(
tv_show_id=int(tv_show_id),
season_id=int(season_id),
properties=["thumbnail", "tvshowid", "seasonid"],
properties=["thumbnail", "tvshowid", "seasonid", "art"],
)
media = media.get("episodes")
if media:
@@ -333,7 +344,9 @@ async def get_media_info(media_library, search_id, search_type):
season_id=int(media[0]["seasonid"]), properties=properties
)
thumbnail = media_library.thumbnail_url(
season["seasondetails"].get("thumbnail")
season["seasondetails"]["art"].get(
"poster", season["seasondetails"].get("thumbnail")
)
)
title = season["seasondetails"]["label"]
@@ -343,6 +356,7 @@ async def get_media_info(media_library, search_id, search_type):
properties=["thumbnail", "channeltype", "channel", "broadcastnow"],
)
media = media.get("channels")
title = "Channels"
return thumbnail, title, media
@@ -23,7 +23,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import (
@@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
assert entry.unique_id
serial = entry.unique_id
client = async_create_clientsession(hass)
client = async_get_clientsession(hass)
cloud_client = LaMarzoccoCloudClient(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
@@ -33,7 +33,7 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
@@ -83,7 +83,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
**user_input,
}
self._client = async_create_clientsession(self.hass)
self._client = async_get_clientsession(self.hass)
cloud_client = LaMarzoccoCloudClient(
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.0.0b7"]
"requirements": ["pylamarzocco==2.0.0"]
}
@@ -89,20 +89,27 @@ class LocalCalendarEntity(CalendarEntity):
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
events = self._calendar.timeline_tz(start_date.tzinfo).overlapping(
start_date,
end_date,
)
return [_get_calendar_event(event) for event in events]
def events_in_range() -> list[CalendarEvent]:
events = self._calendar.timeline_tz(start_date.tzinfo).overlapping(
start_date,
end_date,
)
return [_get_calendar_event(event) for event in events]
return await self.hass.async_add_executor_job(events_in_range)
async def async_update(self) -> None:
"""Update entity state with the next upcoming event."""
now = dt_util.now()
events = self._calendar.timeline_tz(now.tzinfo).active_after(now)
if event := next(events, None):
self._event = _get_calendar_event(event)
else:
self._event = None
def next_event() -> CalendarEvent | None:
now = dt_util.now()
events = self._calendar.timeline_tz(now.tzinfo).active_after(now)
if event := next(events, None):
return _get_calendar_event(event)
return None
self._event = await self.hass.async_add_executor_job(next_event)
async def _async_store(self) -> None:
"""Persist the calendar to disk."""
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
"requirements": ["ical==9.1.0"]
"requirements": ["ical==9.2.0"]
}
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
"requirements": ["ical==9.1.0"]
"requirements": ["ical==9.2.0"]
}
@@ -93,7 +93,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_VOLUME_SET,
required_domains={DOMAIN},
required_states={MediaPlayerState.PLAYING},
required_features=MediaPlayerEntityFeature.VOLUME_SET,
required_slots={
ATTR_MEDIA_VOLUME_LEVEL: intent.IntentSlotInfo(
@@ -159,7 +158,6 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler):
DOMAIN,
SERVICE_MEDIA_PLAY,
required_domains={DOMAIN},
required_states={MediaPlayerState.PAUSED},
description="Resumes a media player",
platforms={DOMAIN},
device_classes={MediaPlayerDeviceClass},
+1 -1
View File
@@ -8,7 +8,7 @@
"iot_class": "cloud_push",
"loggers": ["pymiele"],
"quality_scale": "bronze",
"requirements": ["pymiele==0.4.1"],
"requirements": ["pymiele==0.4.3"],
"single_config_entry": true,
"zeroconf": ["_mieleathome._tcp.local."]
}
-1
View File
@@ -144,7 +144,6 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.COFFEE_SYSTEM,
MieleAppliance.ROBOT_VACUUM_CLEANER,
MieleAppliance.WASHER_DRYER,
MieleAppliance.STEAM_OVEN_COMBI,
MieleAppliance.STEAM_OVEN_MICRO,
+22 -8
View File
@@ -465,7 +465,7 @@ class PlatformField:
required: bool
validator: Callable[..., Any]
error: str | None = None
default: str | int | bool | vol.Undefined = vol.UNDEFINED
default: str | int | bool | None | vol.Undefined = vol.UNDEFINED
is_schema_default: bool = False
exclude_from_reconfig: bool = False
conditions: tuple[dict[str, Any], ...] | None = None
@@ -498,8 +498,7 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get(
CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN
):
errors[CONF_MAX_KELVIN] = "max_below_min_kelvin"
errors[CONF_MIN_KELVIN] = "max_below_min_kelvin"
errors["advanced_settings"] = "max_below_min_kelvin"
return errors
@@ -515,6 +514,7 @@ COMMON_ENTITY_FIELDS = {
required=False,
validator=str,
exclude_from_reconfig=True,
default=None,
),
CONF_ENTITY_PICTURE: PlatformField(
selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url"
@@ -1150,7 +1150,7 @@ ENTITY_CONFIG_VALIDATOR: dict[
}
MQTT_DEVICE_PLATFORM_FIELDS = {
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str),
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True, validator=str),
ATTR_SW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, validator=str
),
@@ -1275,7 +1275,10 @@ def validate_user_input(
try:
validator(value)
except (ValueError, vol.Error, vol.Invalid):
errors[field] = data_schema_fields[field].error or "invalid_input"
data_schema_field = data_schema_fields[field]
errors[data_schema_field.section or field] = (
data_schema_field.error or "invalid_input"
)
if config_validator is not None:
if TYPE_CHECKING:
@@ -1324,7 +1327,10 @@ def data_schema_from_fields(
vol.Required(field_name, default=field_details.default)
if field_details.required
else vol.Optional(
field_name, default=field_details.default
field_name,
default=field_details.default
if field_details.default is not None
else vol.UNDEFINED,
): field_details.selector(component_data_with_user_input) # type: ignore[operator]
if field_details.custom_filtering
else field_details.selector
@@ -1375,12 +1381,17 @@ def data_schema_from_fields(
@callback
def subentry_schema_default_data_from_fields(
data_schema_fields: dict[str, PlatformField],
component_data: dict[str, Any],
) -> dict[str, Any]:
"""Generate custom data schema from platform fields or device data."""
return {
key: field.default
for key, field in data_schema_fields.items()
if field.is_schema_default
if _check_conditions(field, component_data)
and (
field.is_schema_default
or (field.default is not vol.UNDEFINED and key not in component_data)
)
}
@@ -2206,7 +2217,10 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
for component_data in self._subentry_data["components"].values():
platform = component_data[CONF_PLATFORM]
subentry_default_data = subentry_schema_default_data_from_fields(
PLATFORM_ENTITY_FIELDS[platform]
COMMON_ENTITY_FIELDS
| PLATFORM_ENTITY_FIELDS[platform]
| PLATFORM_MQTT_FIELDS[platform],
component_data,
)
component_data.update(subentry_default_data)
+23 -12
View File
@@ -113,14 +113,16 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
_LOGGER.error("Error getting accounts: %s", err)
raise
for account in accounts:
id_prefix = "_".join(
id_prefix = (
(
self.api.utility.subdomain(),
account.meter_type.name.lower(),
# Some utilities like AEP have "-" in their account id.
# Replace it with "_" to avoid "Invalid statistic_id"
account.utility_account_id.replace("-", "_").lower(),
f"{self.api.utility.subdomain()}_{account.meter_type.name}_"
f"{account.utility_account_id}"
)
# Some utilities like AEP have "-" in their account id.
# Other utilities like ngny-gas have "-" in their subdomain.
# Replace it with "_" to avoid "Invalid statistic_id"
.replace("-", "_")
.lower()
)
cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost"
compensation_statistic_id = f"{DOMAIN}:{id_prefix}_energy_compensation"
@@ -190,7 +192,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
return_sum = 0.0
last_stats_time = None
else:
await self._async_maybe_migrate_statistics(
migrated = await self._async_maybe_migrate_statistics(
account.utility_account_id,
{
cost_statistic_id: compensation_statistic_id,
@@ -203,6 +205,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
return_statistic_id: return_metadata,
},
)
if migrated:
# Skip update to avoid working on old data since the migration is done
# asynchronously. Update the statistics in the next refresh in 12h.
_LOGGER.debug(
"Statistics migration completed. Skipping update for now"
)
continue
cost_reads = await self._async_get_cost_reads(
account,
self.api.utility.timezone(),
@@ -326,7 +335,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
utility_account_id: str,
migration_map: dict[str, str],
metadata_map: dict[str, StatisticMetaData],
) -> None:
) -> bool:
"""Perform one-time statistics migration based on the provided map.
Splits negative values from source IDs into target IDs.
@@ -339,7 +348,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
"""
if not migration_map:
return
return False
need_migration_source_ids = set()
for source_id, target_id in migration_map.items():
@@ -354,7 +363,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
if not last_target_stat:
need_migration_source_ids.add(source_id)
if not need_migration_source_ids:
return
return False
_LOGGER.info("Starting one-time migration for: %s", need_migration_source_ids)
@@ -416,7 +425,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
if not need_migration_source_ids:
_LOGGER.debug("No migration needed")
return
return False
for stat_id, stats in processed_stats.items():
_LOGGER.debug("Applying %d migrated stats for %s", len(stats), stat_id)
@@ -434,7 +443,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
"energy_settings": "/config/energy",
"target_ids": "\n".join(
{
v
str(metadata_map[v]["name"])
for k, v in migration_map.items()
if k in need_migration_source_ids
}
@@ -442,6 +451,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
},
)
return True
async def _async_get_cost_reads(
self, account: Account, time_zone_str: str, start_time: float | None = None
) -> list[CostRead]:
+1 -1
View File
@@ -35,7 +35,7 @@
"issues": {
"return_to_grid_migration": {
"title": "Return to grid statistics for account: {utility_account_id}",
"description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those in separate return statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Return to grid** section:\n\n{target_ids}"
"description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those in separate return statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Return to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue."
}
}
}
+7 -2
View File
@@ -56,10 +56,15 @@ class PicoProvider(Provider):
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf:
fname = tmpf.name
cmd = ["pico2wave", "--wave", fname, "-l", language, "--", message]
subprocess.call(cmd)
cmd = ["pico2wave", "--wave", fname, "-l", language]
result = subprocess.run(cmd, text=True, input=message, check=False)
data = None
try:
if result.returncode != 0:
_LOGGER.error(
"Error running pico2wave, return code: %s", result.returncode
)
return (None, None)
with open(fname, "rb") as voice:
data = voice.read()
except OSError:
+7 -2
View File
@@ -29,6 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bo
"""Set up Rehlko from a config entry."""
websession = async_get_clientsession(hass)
rehlko = AioKem(session=websession)
# If requests take more than 20 seconds; timeout and let the setup retry.
rehlko.set_timeout(20)
async def async_refresh_token_update(refresh_token: str) -> None:
"""Handle refresh token update."""
@@ -40,7 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bo
)
rehlko.set_refresh_token_callback(async_refresh_token_update)
rehlko.set_retry_policy(retry_count=3, retry_delays=[5, 10, 20])
try:
await rehlko.authenticate(
@@ -48,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bo
entry.data[CONF_PASSWORD],
entry.data.get(CONF_REFRESH_TOKEN),
)
homes = await rehlko.get_homes()
except AuthenticationError as ex:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
@@ -60,7 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bo
translation_key="cannot_connect",
) from ex
coordinators: dict[int, RehlkoUpdateCoordinator] = {}
homes = await rehlko.get_homes()
entry.runtime_data = RehlkoRuntimeData(
coordinators=coordinators,
@@ -86,6 +87,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bo
await coordinator.async_config_entry_first_refresh()
coordinators[device_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Retrys enabled after successful connection to prevent blocking startup
rehlko.set_retry_policy(retry_count=3, retry_delays=[5, 10, 20])
# Rehlko service can be slow to respond, increase timeout for polls.
rehlko.set_timeout(100)
return True
@@ -13,5 +13,5 @@
"iot_class": "cloud_polling",
"loggers": ["aiokem"],
"quality_scale": "silver",
"requirements": ["aiokem==0.5.9"]
"requirements": ["aiokem==0.5.10"]
}
@@ -29,7 +29,7 @@ async def async_setup_entry(
"""Set up the remote calendar platform."""
coordinator = entry.runtime_data
entity = RemoteCalendarEntity(coordinator, entry)
async_add_entities([entity])
async_add_entities([entity], True)
class RemoteCalendarEntity(
@@ -48,25 +48,46 @@ class RemoteCalendarEntity(
super().__init__(coordinator)
self._attr_name = entry.data[CONF_CALENDAR_NAME]
self._attr_unique_id = entry.entry_id
self._event: CalendarEvent | None = None
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
now = dt_util.now()
events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now)
if event := next(events, None):
return _get_calendar_event(event)
return None
return self._event
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
events = self.coordinator.data.timeline_tz(start_date.tzinfo).overlapping(
start_date,
end_date,
)
return [_get_calendar_event(event) for event in events]
def events_in_range() -> list[CalendarEvent]:
"""Return all events in the given time range."""
events = self.coordinator.data.timeline_tz(start_date.tzinfo).overlapping(
start_date,
end_date,
)
return [_get_calendar_event(event) for event in events]
return await self.hass.async_add_executor_job(events_in_range)
async def async_update(self) -> None:
"""Refresh the timeline.
This is called when the coordinator updates. Creating the timeline may
require walking through the entire calendar and handling recurring
events, so it is done as a separate task without blocking the event loop.
"""
await super().async_update()
def next_timeline_event() -> CalendarEvent | None:
"""Return the next active event."""
now = dt_util.now()
events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now)
if event := next(events, None):
return _get_calendar_event(event)
return None
self._event = await self.hass.async_add_executor_job(next_timeline_event)
def _get_calendar_event(event: Event) -> CalendarEvent:
@@ -5,8 +5,6 @@ import logging
from typing import Any
from httpx import HTTPError, InvalidURL
from ical.calendar_stream import IcsCalendarStream
from ical.exceptions import CalendarParseError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -14,6 +12,7 @@ from homeassistant.const import CONF_URL
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_CALENDAR_NAME, DOMAIN
from .ics import InvalidIcsException, parse_calendar
_LOGGER = logging.getLogger(__name__)
@@ -64,15 +63,9 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("An error occurred: %s", err)
else:
try:
await self.hass.async_add_executor_job(
IcsCalendarStream.calendar_from_ics, res.text
)
except CalendarParseError as err:
await parse_calendar(self.hass, res.text)
except InvalidIcsException:
errors["base"] = "invalid_ics_file"
_LOGGER.error("Error reading the calendar information: %s", err.message)
_LOGGER.debug(
"Additional calendar error detail: %s", str(err.detailed_error)
)
else:
return self.async_create_entry(
title=user_input[CONF_CALENDAR_NAME], data=user_input
@@ -5,8 +5,6 @@ import logging
from httpx import HTTPError, InvalidURL
from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream
from ical.exceptions import CalendarParseError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
@@ -15,6 +13,7 @@ from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .ics import InvalidIcsException, parse_calendar
type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator]
@@ -56,14 +55,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
translation_placeholders={"err": str(err)},
) from err
try:
# calendar_from_ics will dynamically load packages
# the first time it is called, so we need to do it
# in a separate thread to avoid blocking the event loop
self.ics = res.text
return await self.hass.async_add_executor_job(
IcsCalendarStream.calendar_from_ics, self.ics
)
except CalendarParseError as err:
return await parse_calendar(self.hass, res.text)
except InvalidIcsException as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="unable_to_parse",
@@ -0,0 +1,44 @@
"""Module for parsing ICS content.
This module exists to fix known issues where calendar providers return calendars
that do not follow rfcc5545. This module will attempt to fix the calendar and return
a valid calendar object.
"""
import logging
from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream
from ical.compat import enable_compat_mode
from ical.exceptions import CalendarParseError
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
class InvalidIcsException(Exception):
"""Exception to indicate that the ICS content is invalid."""
def _compat_calendar_from_ics(ics: str) -> Calendar:
"""Parse the ICS content and return a Calendar object.
This function is called in a separate thread to avoid blocking the event
loop while loading packages or parsing the ICS content for large calendars.
It uses the `enable_compat_mode` context manager to fix known issues with
calendar providers that return invalid calendars.
"""
with enable_compat_mode(ics) as compat_ics:
return IcsCalendarStream.calendar_from_ics(compat_ics)
async def parse_calendar(hass: HomeAssistant, ics: str) -> Calendar:
"""Parse the ICS content and return a Calendar object."""
try:
return await hass.async_add_executor_job(_compat_calendar_from_ics, ics)
except CalendarParseError as err:
_LOGGER.error("Error parsing calendar information: %s", err.message)
_LOGGER.debug("Additional calendar error detail: %s", str(err.detailed_error))
raise InvalidIcsException(err.message) from err
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==9.1.0"]
"requirements": ["ical==9.2.0"]
}
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.3.0"]
"requirements": ["renault-api==0.3.1"]
}
@@ -19,7 +19,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==2.16.1",
"vacuum-map-parser-roborock==0.1.2"
"python-roborock==2.18.2",
"vacuum-map-parser-roborock==0.1.4"
]
}
+1 -1
View File
@@ -180,7 +180,7 @@ class TTSMediaSource(MediaSource):
raise BrowseError("Unknown provider")
if isinstance(engine_instance, TextToSpeechEntity):
engine_domain = engine_instance.platform.domain
engine_domain = engine_instance.platform.platform_name
else:
engine_domain = engine
@@ -40,7 +40,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"requirements": ["uiprotect==7.5.5", "unifi-discovery==1.2.0"],
"requirements": ["uiprotect==7.6.0", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
@@ -51,9 +51,9 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
_PIPELINE_TIMEOUT_SEC: Final = 30
_HANGUP_SEC: Final = 0.5
_ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5
_ANNOUNCEMENT_AFTER_DELAY: Final = 1.0
_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5
_ANNOUNCEMENT_RING_TIMEOUT: Final = 30
@@ -132,9 +132,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
self._processing_tone_done = asyncio.Event()
self._announcement: AssistSatelliteAnnouncement | None = None
self._announcement_future: asyncio.Future[Any] = asyncio.Future()
self._announcment_start_time: float = 0.0
self._check_announcement_ended_task: asyncio.Task | None = None
self._check_announcement_pickup_task: asyncio.Task | None = None
self._check_hangup_task: asyncio.Task | None = None
self._call_end_future: asyncio.Future[Any] = asyncio.Future()
self._last_chunk_time: float | None = None
self._rtp_port: int | None = None
self._run_pipeline_after_announce: bool = False
@@ -233,7 +234,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
translation_key="non_tts_announcement",
)
self._announcement_future = asyncio.Future()
self._call_end_future = asyncio.Future()
self._run_pipeline_after_announce = run_pipeline_after
if self._rtp_port is None:
@@ -274,53 +275,77 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
rtp_port=self._rtp_port,
)
# Check if caller hung up or didn't pick up
self._check_announcement_ended_task = (
# Check if caller didn't pick up
self._check_announcement_pickup_task = (
self.config_entry.async_create_background_task(
self.hass,
self._check_announcement_ended(),
"voip_announcement_ended",
self._check_announcement_pickup(),
"voip_announcement_pickup",
)
)
try:
await self._announcement_future
await self._call_end_future
except TimeoutError:
# Stop ringing
_LOGGER.debug("Caller did not pick up in time")
sip_protocol.cancel_call(call_info)
raise
async def _check_announcement_ended(self) -> None:
async def _check_announcement_pickup(self) -> None:
"""Continuously checks if an audio chunk was received within a time limit.
If not, the caller is presumed to have hung up and the announcement is ended.
If not, the caller is presumed to have not picked up the phone and the announcement is ended.
"""
while self._announcement is not None:
while True:
current_time = time.monotonic()
if (self._last_chunk_time is None) and (
(current_time - self._announcment_start_time)
> _ANNOUNCEMENT_RING_TIMEOUT
):
# Ring timeout
_LOGGER.debug("Ring timeout")
self._announcement = None
self._check_announcement_ended_task = None
self._announcement_future.set_exception(
self._check_announcement_pickup_task = None
self._call_end_future.set_exception(
TimeoutError("User did not pick up in time")
)
_LOGGER.debug("Timed out waiting for the user to pick up the phone")
break
if (self._last_chunk_time is not None) and (
(current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC
):
# Caller hung up
self._announcement = None
self._announcement_future.set_result(None)
self._check_announcement_ended_task = None
_LOGGER.debug("Announcement ended")
if self._last_chunk_time is not None:
_LOGGER.debug("Picked up the phone")
self._check_announcement_pickup_task = None
break
await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2)
await asyncio.sleep(_HANGUP_SEC / 2)
async def _check_hangup(self) -> None:
"""Continuously checks if an audio chunk was received within a time limit.
If not, the caller is presumed to have hung up and the call is ended.
"""
try:
while True:
current_time = time.monotonic()
if (self._last_chunk_time is not None) and (
(current_time - self._last_chunk_time) > _HANGUP_SEC
):
# Caller hung up
_LOGGER.debug("Hang up")
self._announcement = None
if self._run_pipeline_task is not None:
_LOGGER.debug("Cancelling running pipeline")
self._run_pipeline_task.cancel()
self._call_end_future.set_result(None)
self.disconnect()
break
await asyncio.sleep(_HANGUP_SEC / 2)
except asyncio.CancelledError:
# Don't swallow cancellation
if (current_task := asyncio.current_task()) and current_task.cancelling():
raise
_LOGGER.debug("Check hangup cancelled")
async def async_start_conversation(
self, start_announcement: AssistSatelliteAnnouncement
@@ -332,6 +357,24 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
# VoIP
# -------------------------------------------------------------------------
def disconnect(self):
"""Server disconnected."""
super().disconnect()
if self._check_hangup_task is not None:
self._check_hangup_task.cancel()
self._check_hangup_task = None
def connection_made(self, transport):
"""Server is ready."""
super().connection_made(transport)
self._last_chunk_time = time.monotonic()
# Check if caller hung up
self._check_hangup_task = self.config_entry.async_create_background_task(
self.hass,
self._check_hangup(),
"voip_hangup",
)
def on_chunk(self, audio_bytes: bytes) -> None:
"""Handle raw audio chunk."""
self._last_chunk_time = time.monotonic()
@@ -368,13 +411,22 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
self.voip_device.set_is_active(True)
async def stt_stream():
retry: bool = True
while True:
async with asyncio.timeout(self._audio_chunk_timeout):
chunk = await self._audio_queue.get()
if not chunk:
break
try:
async with asyncio.timeout(self._audio_chunk_timeout):
chunk = await self._audio_queue.get()
if not chunk:
_LOGGER.debug("STT stream got None")
break
yield chunk
except TimeoutError:
_LOGGER.debug("STT Stream timed out")
if not retry:
_LOGGER.debug("No more retries, ending STT stream")
break
retry = False
# Play listening tone at the start of each cycle
await self._play_tone(Tones.LISTENING, silence_before=0.2)
@@ -385,6 +437,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
)
if self._pipeline_had_error:
_LOGGER.debug("Pipeline error")
self._pipeline_had_error = False
await self._play_tone(Tones.ERROR)
else:
@@ -394,7 +447,14 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
# length of the TTS audio.
await self._tts_done.wait()
except TimeoutError:
# This shouldn't happen anymore, we are detecting hang ups with a separate task
_LOGGER.exception("Timeout error")
self.disconnect() # caller hung up
except asyncio.CancelledError:
_LOGGER.debug("Pipeline cancelled")
# Don't swallow cancellation
if (current_task := asyncio.current_task()) and current_task.cancelling():
raise
finally:
# Stop audio stream
await self._audio_queue.put(None)
@@ -433,8 +493,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
if self._run_pipeline_after_announce:
# Clear announcement to allow pipeline to run
_LOGGER.debug("Clearing announcement")
self._announcement = None
self._announcement_future.set_result(None)
def _clear_audio_queue(self) -> None:
"""Ensure audio queue is empty."""
@@ -463,6 +523,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
)
else:
# Empty TTS response
_LOGGER.debug("Empty TTS response")
self._tts_done.set()
elif event.type == PipelineEventType.ERROR:
# Play error tone instead of wait for TTS when pipeline is finished.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"domain": "voip",
"name": "Voice over IP",
"codeowners": ["@balloob", "@synesthesiam"],
"codeowners": ["@balloob", "@synesthesiam", "@jaminh"],
"config_flow": true,
"dependencies": ["assist_pipeline", "assist_satellite", "intent", "network"],
"documentation": "https://www.home-assistant.io/integrations/voip",
"iot_class": "local_push",
"loggers": ["voip_utils"],
"quality_scale": "internal",
"requirements": ["voip-utils==0.3.1"]
"requirements": ["voip-utils==0.3.2"]
}
@@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
"iot_class": "local_push",
"requirements": ["xiaomi-ble==0.37.0"]
"requirements": ["xiaomi-ble==0.38.0"]
}
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["zeroconf"],
"quality_scale": "internal",
"requirements": ["zeroconf==0.146.5"]
"requirements": ["zeroconf==0.147.0"]
}
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from datetime import datetime
import logging
from pathlib import Path
@@ -77,6 +78,7 @@ ADDON_SETUP_TIMEOUT = 5
ADDON_SETUP_TIMEOUT_ROUNDS = 40
CONF_EMULATE_HARDWARE = "emulate_hardware"
CONF_LOG_LEVEL = "log_level"
RESTORE_NVM_DRIVER_READY_TIMEOUT = 60
SERVER_VERSION_TIMEOUT = 10
ADDON_LOG_LEVELS = {
@@ -461,10 +463,18 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
if vid == "10C4" and pid == "EA60" and description and "2652" in description:
return self.async_abort(reason="not_zwave_device")
discovery_info.device = await self.hass.async_add_executor_job(
usb.get_serial_by_id, discovery_info.device
)
addon_info = await self._async_get_addon_info()
if (
addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.INSTALLING)
and addon_info.options.get(CONF_ADDON_DEVICE) == discovery_info.device
and (addon_device := addon_info.options.get(CONF_ADDON_DEVICE)) is not None
and await self.hass.async_add_executor_job(
usb.get_serial_by_id, addon_device
)
== discovery_info.device
):
return self.async_abort(reason="already_configured")
@@ -717,7 +727,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema = vol.Schema(schema)
return self.async_show_form(step_id="configure_addon", data_schema=data_schema)
return self.async_show_form(
step_id="configure_addon_user", data_schema=data_schema
)
async def async_step_finish_addon_setup_user(
self, user_input: dict[str, Any] | None = None
@@ -895,10 +907,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Reset the current controller, and instruct the user to unplug it."""
if user_input is not None:
config_entry = self._reconfigure_config_entry
assert config_entry is not None
# Unload the config entry before stopping the add-on.
await self.hass.config_entries.async_unload(config_entry.entry_id)
if self.usb_path:
# USB discovery was used, so the device is already known.
await self._async_set_addon_config({CONF_ADDON_DEVICE: self.usb_path})
@@ -913,6 +921,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("Failed to reset controller: %s", err)
return self.async_abort(reason="reset_failed")
config_entry = self._reconfigure_config_entry
assert config_entry is not None
# Unload the config entry before asking the user to unplug the controller.
await self.hass.config_entries.async_unload(config_entry.entry_id)
return self.async_show_form(
step_id="instruct_unplug",
description_placeholders={
@@ -1097,7 +1110,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
}
)
return self.async_show_form(step_id="configure_addon", data_schema=data_schema)
return self.async_show_form(
step_id="configure_addon_reconfigure", data_schema=data_schema
)
async def async_step_choose_serial_port(
self, user_input: dict[str, Any] | None = None
@@ -1305,15 +1320,28 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
event["bytesWritten"] / event["total"] * 0.5 + 0.5
)
controller = self._get_driver().controller
@callback
def set_driver_ready(event: dict) -> None:
"Set the driver ready event."
wait_driver_ready.set()
driver = self._get_driver()
controller = driver.controller
wait_driver_ready = asyncio.Event()
unsubs = [
controller.on("nvm convert progress", forward_progress),
controller.on("nvm restore progress", forward_progress),
driver.once("driver ready", set_driver_ready),
]
try:
await controller.async_restore_nvm(self.backup_data)
except FailedCommand as err:
raise AbortFlow(f"Failed to restore network: {err}") from err
else:
with suppress(TimeoutError):
async with asyncio.timeout(RESTORE_NVM_DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait()
await self.hass.config_entries.async_reload(config_entry.entry_id)
finally:
for unsub in unsubs:
unsub()
+11 -7
View File
@@ -37,8 +37,10 @@
"restore_nvm": "Please wait while the network restore completes."
},
"step": {
"configure_addon": {
"configure_addon_user": {
"data": {
"lr_s2_access_control_key": "Long Range S2 Access Control Key",
"lr_s2_authenticated_key": "Long Range S2 Authenticated Key",
"s0_legacy_key": "S0 Key (Legacy)",
"s2_access_control_key": "S2 Access Control Key",
"s2_authenticated_key": "S2 Authenticated Key",
@@ -52,14 +54,16 @@
"data": {
"emulate_hardware": "Emulate Hardware",
"log_level": "Log level",
"s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon::data::s0_legacy_key%]",
"s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_access_control_key%]",
"s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_authenticated_key%]",
"s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_unauthenticated_key%]",
"lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_access_control_key%]",
"lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_authenticated_key%]",
"s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s0_legacy_key%]",
"s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_access_control_key%]",
"s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_authenticated_key%]",
"s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_unauthenticated_key%]",
"usb_path": "[%key:common::config_flow::data::usb_path%]"
},
"description": "[%key:component::zwave_js::config::step::configure_addon::description%]",
"title": "[%key:component::zwave_js::config::step::configure_addon::title%]"
"description": "[%key:component::zwave_js::config::step::configure_addon_user::description%]",
"title": "[%key:component::zwave_js::config::step::configure_addon_user::title%]"
},
"hassio_confirm": {
"description": "Do you want to set up the Z-Wave integration with the Z-Wave add-on?"
+1 -1
View File
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "0b2"
PATCH_VERSION: Final = "0b8"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
+1 -1
View File
@@ -75,6 +75,7 @@ FLOWS = {
"aussie_broadband",
"autarco",
"awair",
"aws_s3",
"axis",
"azure_data_explorer",
"azure_devops",
@@ -541,7 +542,6 @@ FLOWS = {
"ruuvi_gateway",
"ruuvitag_ble",
"rympro",
"s3",
"sabnzbd",
"samsungtv",
"sanix",
+6 -6
View File
@@ -219,6 +219,12 @@
"iot_class": "cloud_push",
"name": "Amazon Web Services (AWS)"
},
"aws_s3": {
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_push",
"name": "AWS S3"
},
"fire_tv": {
"integration_type": "virtual",
"config_flow": false,
@@ -5622,12 +5628,6 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"s3": {
"name": "S3",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_push"
},
"sabnzbd": {
"name": "SABnzbd",
"integration_type": "hub",
+3 -3
View File
@@ -34,11 +34,11 @@ dbus-fast==2.43.0
fnv-hash-fast==1.5.0
go2rtc-client==0.1.2
ha-ffmpeg==3.2.2
habluetooth==3.47.1
habluetooth==3.48.2
hass-nabucasa==0.96.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250502.0
home-assistant-frontend==20250506.0
home-assistant-intents==2025.4.30
httpx==0.28.1
ifaddr==0.2.0
@@ -75,7 +75,7 @@ voluptuous-serialize==2.6.0
voluptuous==0.15.2
webrtc-models==0.3.0
yarl==1.20.0
zeroconf==0.146.5
zeroconf==0.147.0
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.5.0b2"
version = "2025.5.0b8"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
@@ -123,7 +123,7 @@ dependencies = [
"voluptuous-openapi==0.0.7",
"yarl==1.20.0",
"webrtc-models==0.3.0",
"zeroconf==0.146.5",
"zeroconf==0.147.0",
]
[project.urls]
+1 -1
View File
@@ -60,4 +60,4 @@ voluptuous-serialize==2.6.0
voluptuous-openapi==0.0.7
yarl==1.20.0
webrtc-models==0.3.0
zeroconf==0.146.5
zeroconf==0.147.0
+16 -16
View File
@@ -210,7 +210,7 @@ aioazuredevops==2.2.1
aiobafi6==0.9.0
# homeassistant.components.aws
# homeassistant.components.s3
# homeassistant.components.aws_s3
aiobotocore==2.21.1
# homeassistant.components.comelit
@@ -286,7 +286,7 @@ aiokafka==0.10.0
aiokef==0.2.16
# homeassistant.components.rehlko
aiokem==0.5.9
aiokem==0.5.10
# homeassistant.components.lifx
aiolifx-effects==0.3.2
@@ -628,7 +628,7 @@ blockchain==1.4.4
bluecurrent-api==1.2.3
# homeassistant.components.bluemaestro
bluemaestro-ble==0.4.0
bluemaestro-ble==0.4.1
# homeassistant.components.decora
# bluepy==1.3.0
@@ -1118,7 +1118,7 @@ ha-silabs-firmware-client==0.2.0
habiticalib==0.3.7
# homeassistant.components.bluetooth
habluetooth==3.47.1
habluetooth==3.48.2
# homeassistant.components.cloud
hass-nabucasa==0.96.0
@@ -1161,13 +1161,13 @@ hole==0.8.0
holidays==0.70
# homeassistant.components.frontend
home-assistant-frontend==20250502.0
home-assistant-frontend==20250506.0
# homeassistant.components.conversation
home-assistant-intents==2025.4.30
# homeassistant.components.homematicip_cloud
homematicip==2.0.1
homematicip==2.0.1.1
# homeassistant.components.horizon
horimote==0.4.1
@@ -1200,7 +1200,7 @@ ibmiotf==0.3.4
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==9.1.0
ical==9.2.0
# homeassistant.components.caldav
icalendar==6.1.0
@@ -2093,7 +2093,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
pylamarzocco==2.0.0b7
pylamarzocco==2.0.0
# homeassistant.components.lastfm
pylast==5.1.0
@@ -2135,7 +2135,7 @@ pymeteoclimatic==0.1.0
pymicro-vad==1.0.1
# homeassistant.components.miele
pymiele==0.4.1
pymiele==0.4.3
# homeassistant.components.xiaomi_tv
pymitv==1.4.3
@@ -2480,7 +2480,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==2.16.1
python-roborock==2.18.2
# homeassistant.components.smarttub
python-smarttub==0.0.39
@@ -2631,7 +2631,7 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.3.0
renault-api==0.3.1
# homeassistant.components.renson
renson-endura-delta==1.7.2
@@ -2975,7 +2975,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==7.5.5
uiprotect==7.6.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -3007,7 +3007,7 @@ url-normalize==2.2.1
uvcclient==0.12.1
# homeassistant.components.roborock
vacuum-map-parser-roborock==0.1.2
vacuum-map-parser-roborock==0.1.4
# homeassistant.components.vallox
vallox-websocket-api==5.3.0
@@ -3025,7 +3025,7 @@ venstarcolortouch==0.19
vilfo-api-client==0.5.0
# homeassistant.components.voip
voip-utils==0.3.1
voip-utils==0.3.2
# homeassistant.components.volkszaehler
volkszaehler==0.4.0
@@ -3101,7 +3101,7 @@ wyoming==1.5.4
xbox-webapi==2.1.0
# homeassistant.components.xiaomi_ble
xiaomi-ble==0.37.0
xiaomi-ble==0.38.0
# homeassistant.components.knx
xknx==3.6.0
@@ -3156,7 +3156,7 @@ zabbix-utils==2.0.2
zamg==0.3.6
# homeassistant.components.zeroconf
zeroconf==0.146.5
zeroconf==0.147.0
# homeassistant.components.zeversolar
zeversolar==0.3.2
+16 -16
View File
@@ -198,7 +198,7 @@ aioazuredevops==2.2.1
aiobafi6==0.9.0
# homeassistant.components.aws
# homeassistant.components.s3
# homeassistant.components.aws_s3
aiobotocore==2.21.1
# homeassistant.components.comelit
@@ -268,7 +268,7 @@ aioimaplib==2.0.1
aiokafka==0.10.0
# homeassistant.components.rehlko
aiokem==0.5.9
aiokem==0.5.10
# homeassistant.components.lifx
aiolifx-effects==0.3.2
@@ -556,7 +556,7 @@ blinkpy==0.23.0
bluecurrent-api==1.2.3
# homeassistant.components.bluemaestro
bluemaestro-ble==0.4.0
bluemaestro-ble==0.4.1
# homeassistant.components.bluetooth
bluetooth-adapters==0.21.4
@@ -960,7 +960,7 @@ ha-silabs-firmware-client==0.2.0
habiticalib==0.3.7
# homeassistant.components.bluetooth
habluetooth==3.47.1
habluetooth==3.48.2
# homeassistant.components.cloud
hass-nabucasa==0.96.0
@@ -991,13 +991,13 @@ hole==0.8.0
holidays==0.70
# homeassistant.components.frontend
home-assistant-frontend==20250502.0
home-assistant-frontend==20250506.0
# homeassistant.components.conversation
home-assistant-intents==2025.4.30
# homeassistant.components.homematicip_cloud
homematicip==2.0.1
homematicip==2.0.1.1
# homeassistant.components.remember_the_milk
httplib2==0.20.4
@@ -1021,7 +1021,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==9.1.0
ical==9.2.0
# homeassistant.components.caldav
icalendar==6.1.0
@@ -1708,7 +1708,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.8
# homeassistant.components.lamarzocco
pylamarzocco==2.0.0b7
pylamarzocco==2.0.0
# homeassistant.components.lastfm
pylast==5.1.0
@@ -1747,7 +1747,7 @@ pymeteoclimatic==0.1.0
pymicro-vad==1.0.1
# homeassistant.components.miele
pymiele==0.4.1
pymiele==0.4.3
# homeassistant.components.mochad
pymochad==0.2.0
@@ -2017,7 +2017,7 @@ python-picnic-api2==1.2.4
python-rabbitair==0.0.8
# homeassistant.components.roborock
python-roborock==2.16.1
python-roborock==2.18.2
# homeassistant.components.smarttub
python-smarttub==0.0.39
@@ -2138,7 +2138,7 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.3.0
renault-api==0.3.1
# homeassistant.components.renson
renson-endura-delta==1.7.2
@@ -2404,7 +2404,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==7.5.5
uiprotect==7.6.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2430,7 +2430,7 @@ url-normalize==2.2.1
uvcclient==0.12.1
# homeassistant.components.roborock
vacuum-map-parser-roborock==0.1.2
vacuum-map-parser-roborock==0.1.4
# homeassistant.components.vallox
vallox-websocket-api==5.3.0
@@ -2448,7 +2448,7 @@ venstarcolortouch==0.19
vilfo-api-client==0.5.0
# homeassistant.components.voip
voip-utils==0.3.1
voip-utils==0.3.2
# homeassistant.components.volvooncall
volvooncall==0.10.3
@@ -2509,7 +2509,7 @@ wyoming==1.5.4
xbox-webapi==2.1.0
# homeassistant.components.xiaomi_ble
xiaomi-ble==0.37.0
xiaomi-ble==0.38.0
# homeassistant.components.knx
xknx==3.6.0
@@ -2555,7 +2555,7 @@ yt-dlp[default]==2025.03.31
zamg==0.3.6
# homeassistant.components.zeroconf
zeroconf==0.146.5
zeroconf==0.147.0
# homeassistant.components.zeversolar
zeversolar==0.3.2
-1
View File
@@ -208,7 +208,6 @@ EXCEPTIONS = {
# https://github.com/jaraco/skeleton/pull/170
# https://github.com/jaraco/skeleton/pull/171
"jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21
"setuptools", # MIT
}
TODO = {
@@ -1,4 +1,4 @@
"""Tests for the S3 integration."""
"""Tests for the AWS S3 integration."""
from homeassistant.core import HomeAssistant
@@ -1,4 +1,4 @@
"""Common fixtures for the S3 tests."""
"""Common fixtures for the AWS S3 tests."""
from collections.abc import AsyncIterator, Generator
import json
@@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.backup import AgentBackup
from homeassistant.components.s3.backup import (
from homeassistant.components.aws_s3.backup import (
MULTIPART_MIN_PART_SIZE_BYTES,
suggested_filenames,
)
from homeassistant.components.s3.const import DOMAIN
from homeassistant.components.aws_s3.const import DOMAIN
from homeassistant.components.backup import AgentBackup
from .const import USER_INPUT
@@ -1,6 +1,6 @@
"""Consts for S3 tests."""
"""Consts for AWS S3 tests."""
from homeassistant.components.s3.const import (
from homeassistant.components.aws_s3.const import (
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_ENDPOINT_URL,
@@ -10,6 +10,6 @@ from homeassistant.components.s3.const import (
USER_INPUT = {
CONF_ACCESS_KEY_ID: "TestTestTestTestTest",
CONF_SECRET_ACCESS_KEY: "TestTestTestTestTestTestTestTestTestTest",
CONF_ENDPOINT_URL: "http://127.0.0.1:9000",
CONF_ENDPOINT_URL: "https://s3.eu-south-1.amazonaws.com",
CONF_BUCKET: "test",
}
@@ -1,4 +1,4 @@
"""Test the S3 backup platform."""
"""Test the AWS S3 backup platform."""
from collections.abc import AsyncGenerator
from io import StringIO
@@ -9,19 +9,19 @@ from unittest.mock import AsyncMock, Mock, patch
from botocore.exceptions import ConnectTimeoutError
import pytest
from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup
from homeassistant.components.s3.backup import (
from homeassistant.components.aws_s3.backup import (
MULTIPART_MIN_PART_SIZE_BYTES,
BotoCoreError,
S3BackupAgent,
async_register_backup_agents_listener,
suggested_filenames,
)
from homeassistant.components.s3.const import (
from homeassistant.components.aws_s3.const import (
CONF_ENDPOINT_URL,
DATA_BACKUP_AGENT_LISTENERS,
DOMAIN,
)
from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup
from homeassistant.core import HomeAssistant
from homeassistant.helpers.backup import async_initialize_backup
from homeassistant.setup import async_setup_component
@@ -362,7 +362,7 @@ async def test_agents_upload_network_failure(
)
assert resp.status == 201
assert "Upload failed for s3" in caplog.text
assert "Upload failed for aws_s3" in caplog.text
async def test_agents_download(
@@ -1,4 +1,4 @@
"""Test the S3 config flow."""
"""Test the AWS S3 config flow."""
from unittest.mock import AsyncMock, patch
@@ -10,7 +10,7 @@ from botocore.exceptions import (
import pytest
from homeassistant import config_entries
from homeassistant.components.s3.const import CONF_BUCKET, CONF_ENDPOINT_URL, DOMAIN
from homeassistant.components.aws_s3.const import CONF_BUCKET, CONF_ENDPOINT_URL, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -21,8 +21,12 @@ from tests.common import MockConfigEntry
async def _async_start_flow(
hass: HomeAssistant,
user_input: dict[str, str] | None = None,
) -> FlowResultType:
"""Initialize the config flow."""
if user_input is None:
user_input = USER_INPUT
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -30,7 +34,7 @@ async def _async_start_flow(
return await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
user_input,
)
@@ -116,3 +120,24 @@ async def test_abort_if_already_configured(
result = await _async_start_flow(hass)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_flow_create_not_aws_endpoint(
hass: HomeAssistant,
) -> None:
"""Test config flow with a not aws endpoint should raise an error."""
result = await _async_start_flow(
hass, USER_INPUT | {CONF_ENDPOINT_URL: "http://example.com"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {CONF_ENDPOINT_URL: "invalid_endpoint_url"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test"
assert result["data"] == USER_INPUT
@@ -1,8 +1,7 @@
"""Test the s3 storage integration."""
"""Test the AWS S3 storage integration."""
from unittest.mock import AsyncMock, patch
from botocore.config import Config
from botocore.exceptions import (
ClientError,
EndpointConnectionError,
@@ -74,19 +73,3 @@ async def test_setup_entry_head_bucket_error(
)
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
async def test_checksum_settings_present(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test that checksum validation is set to be compatible with third-party S3 providers."""
# due to https://github.com/home-assistant/core/issues/143995
with patch(
"homeassistant.components.s3.AioSession.create_client"
) as mock_create_client:
await setup_integration(hass, mock_config_entry)
config_arg = mock_create_client.call_args[1]["config"]
assert isinstance(config_arg, Config)
assert config_arg.request_checksum_calculation == "when_required"
assert config_arg.response_checksum_validation == "when_required"
+152
View File
@@ -10,8 +10,11 @@ from aioesphomeapi import (
BinarySensorState,
SensorInfo,
SensorState,
build_unique_id,
)
import pytest
from homeassistant.components.esphome import DOMAIN
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_RESTORED,
@@ -19,6 +22,7 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
@@ -513,3 +517,151 @@ async def test_entity_without_name_device_with_friendly_name(
# Make sure we have set the name to `None` as otherwise
# the friendly_name will be "The Best Mixer "
assert state.attributes[ATTR_FRIENDLY_NAME] == "The Best Mixer"
@pytest.mark.usefixtures("hass_storage")
async def test_entity_id_preserved_on_upgrade(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
entity_registry: er.EntityRegistry,
) -> None:
"""Test entity_id is preserved on upgrade."""
entity_info = [
BinarySensorInfo(
object_id="my",
key=1,
name="my",
unique_id="binary_sensor_my",
),
]
states = [
BinarySensorState(key=1, state=True, missing_state=False),
]
user_service = []
assert (
build_unique_id("11:22:33:44:55:AA", entity_info[0])
== "11:22:33:44:55:AA-binary_sensor-my"
)
entry = entity_registry.async_get_or_create(
Platform.BINARY_SENSOR,
DOMAIN,
"11:22:33:44:55:AA-binary_sensor-my",
suggested_object_id="should_not_change",
)
assert entry.entity_id == "binary_sensor.should_not_change"
await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
user_service=user_service,
states=states,
device_info={"friendly_name": "The Best Mixer", "name": "mixer"},
)
state = hass.states.get("binary_sensor.should_not_change")
assert state is not None
@pytest.mark.usefixtures("hass_storage")
async def test_entity_id_preserved_on_upgrade_old_format_entity_id(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
entity_registry: er.EntityRegistry,
) -> None:
"""Test entity_id is preserved on upgrade from old format."""
entity_info = [
BinarySensorInfo(
object_id="my",
key=1,
name="my",
unique_id="binary_sensor_my",
),
]
states = [
BinarySensorState(key=1, state=True, missing_state=False),
]
user_service = []
assert (
build_unique_id("11:22:33:44:55:AA", entity_info[0])
== "11:22:33:44:55:AA-binary_sensor-my"
)
entry = entity_registry.async_get_or_create(
Platform.BINARY_SENSOR,
DOMAIN,
"11:22:33:44:55:AA-binary_sensor-my",
suggested_object_id="my",
)
assert entry.entity_id == "binary_sensor.my"
await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
user_service=user_service,
states=states,
device_info={"name": "mixer"},
)
state = hass.states.get("binary_sensor.my")
assert state is not None
async def test_entity_id_preserved_on_upgrade_when_in_storage(
hass: HomeAssistant,
mock_client: APIClient,
hass_storage: dict[str, Any],
mock_esphome_device: MockESPHomeDeviceType,
entity_registry: er.EntityRegistry,
) -> None:
"""Test entity_id is preserved on upgrade with user defined entity_id."""
entity_info = [
BinarySensorInfo(
object_id="my",
key=1,
name="my",
unique_id="binary_sensor_my",
),
]
states = [
BinarySensorState(key=1, state=True, missing_state=False),
]
user_service = []
device = await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
user_service=user_service,
states=states,
device_info={"friendly_name": "The Best Mixer", "name": "mixer"},
)
state = hass.states.get("binary_sensor.mixer_my")
assert state is not None
# now rename the entity
ent_reg_entry = entity_registry.async_get_or_create(
Platform.BINARY_SENSOR,
DOMAIN,
"11:22:33:44:55:AA-binary_sensor-my",
)
entity_registry.async_update_entity(
ent_reg_entry.entity_id,
new_entity_id="binary_sensor.user_named",
)
await hass.config_entries.async_unload(device.entry.entry_id)
await hass.async_block_till_done()
entry = device.entry
entry_id = entry.entry_id
storage_key = f"esphome.{entry_id}"
assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1
binary_sensor_data: dict[str, Any] = hass_storage[storage_key]["data"][
"binary_sensor"
][0]
assert binary_sensor_data["name"] == "my"
assert binary_sensor_data["object_id"] == "my"
device = await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
user_service=user_service,
states=states,
entry=entry,
device_info={"friendly_name": "The Best Mixer", "name": "mixer"},
)
state = hass.states.get("binary_sensor.user_named")
assert state is not None
+109 -4
View File
@@ -211,6 +211,8 @@ async def test_set_temperature(
) -> None:
"""Test setting temperature."""
device = FritzDeviceClimateMock()
device.lock = False
await setup_config_entry(
hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
)
@@ -288,6 +290,8 @@ async def test_set_hvac_mode(
) -> None:
"""Test setting hvac mode."""
device = FritzDeviceClimateMock()
device.lock = False
device.target_temperature = target_temperature
if current_preset is PRESET_COMFORT:
@@ -335,6 +339,8 @@ async def test_set_preset_mode_comfort(
) -> None:
"""Test setting preset mode."""
device = FritzDeviceClimateMock()
device.lock = False
device.comfort_temperature = comfort_temperature
await setup_config_entry(
hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
@@ -366,6 +372,8 @@ async def test_set_preset_mode_eco(
) -> None:
"""Test setting preset mode."""
device = FritzDeviceClimateMock()
device.lock = False
device.eco_temperature = eco_temperature
await setup_config_entry(
hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
@@ -387,6 +395,8 @@ async def test_set_preset_mode_boost(
) -> None:
"""Test setting preset mode."""
device = FritzDeviceClimateMock()
device.lock = False
await setup_config_entry(
hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
)
@@ -471,11 +481,106 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None:
assert state
@pytest.mark.parametrize(
"service_data",
[
{ATTR_TEMPERATURE: 23},
{
ATTR_HVAC_MODE: HVACMode.HEAT,
ATTR_TEMPERATURE: 25,
},
],
)
async def test_set_temperature_lock(
hass: HomeAssistant,
fritz: Mock,
service_data: dict,
) -> None:
"""Test setting temperature while device is locked."""
device = FritzDeviceClimateMock()
device.lock = True
assert await setup_config_entry(
hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
)
with pytest.raises(
HomeAssistantError,
match="Can't change settings while manual access for telephone, app, or user interface is disabled on the device",
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: ENTITY_ID, **service_data},
True,
)
@pytest.mark.parametrize(
("service_data", "target_temperature", "current_preset", "expected_call_args"),
[
# mode off always sets target temperature to 0
({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0, True)]),
({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0, True)]),
({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0, True)]),
# mode heat sets target temperature based on current scheduled preset,
# when not already in mode heat
({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22, True)]),
({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16, True)]),
({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22, True)]),
# mode heat does not set target temperature, when already in mode heat
({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, []),
({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, []),
({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, None, []),
({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_COMFORT, []),
({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_ECO, []),
({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, None, []),
],
)
async def test_set_hvac_mode_lock(
hass: HomeAssistant,
fritz: Mock,
service_data: dict,
target_temperature: float,
current_preset: str,
expected_call_args: list[_Call],
) -> None:
"""Test setting hvac mode while device is locked."""
device = FritzDeviceClimateMock()
device.lock = True
device.target_temperature = target_temperature
if current_preset is PRESET_COMFORT:
device.nextchange_temperature = device.eco_temperature
elif current_preset is PRESET_ECO:
device.nextchange_temperature = device.comfort_temperature
else:
device.nextchange_endperiod = 0
assert await setup_config_entry(
hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
)
with pytest.raises(
HomeAssistantError,
match="Can't change settings while manual access for telephone, app, or user interface is disabled on the device",
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, **service_data},
True,
)
async def test_holidy_summer_mode(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, fritz: Mock
) -> None:
"""Test holiday and summer mode."""
device = FritzDeviceClimateMock()
device.lock = False
await setup_config_entry(
hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
)
@@ -510,7 +615,7 @@ async def test_holidy_summer_mode(
with pytest.raises(
HomeAssistantError,
match="Can't change HVAC mode while holiday or summer mode is active on the device",
match="Can't change settings while holiday or summer mode is active on the device",
):
await hass.services.async_call(
"climate",
@@ -520,7 +625,7 @@ async def test_holidy_summer_mode(
)
with pytest.raises(
HomeAssistantError,
match="Can't change preset while holiday or summer mode is active on the device",
match="Can't change settings while holiday or summer mode is active on the device",
):
await hass.services.async_call(
"climate",
@@ -546,7 +651,7 @@ async def test_holidy_summer_mode(
with pytest.raises(
HomeAssistantError,
match="Can't change HVAC mode while holiday or summer mode is active on the device",
match="Can't change settings while holiday or summer mode is active on the device",
):
await hass.services.async_call(
"climate",
@@ -556,7 +661,7 @@ async def test_holidy_summer_mode(
)
with pytest.raises(
HomeAssistantError,
match="Can't change preset while holiday or summer mode is active on the device",
match="Can't change settings while holiday or summer mode is active on the device",
):
await hass.services.async_call(
"climate",
@@ -1,4 +1,21 @@
# serializer version: 1
# name: test_generate_content_file_processing_succeeds
list([
tuple(
'',
tuple(
),
dict({
'contents': list([
'Describe this image from my doorbell camera',
File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=<FileState.ACTIVE: 'ACTIVE'>, source=None, video_metadata=None, error=None),
File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=<FileState.PROCESSING: 'PROCESSING'>, source=None, video_metadata=None, error=None),
]),
'model': 'models/gemini-2.0-flash',
}),
),
])
# ---
# name: test_generate_content_service_with_image
list([
tuple(
@@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, Mock, mock_open, patch
from google.genai.types import File, FileState
import pytest
from requests.exceptions import Timeout
from syrupy.assertion import SnapshotAssertion
@@ -91,6 +92,117 @@ async def test_generate_content_service_with_image(
assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot
@pytest.mark.usefixtures("mock_init_component")
async def test_generate_content_file_processing_succeeds(
hass: HomeAssistant, snapshot: SnapshotAssertion
) -> None:
"""Test generate content service."""
stubbed_generated_content = (
"A mail carrier is at your front door delivering a package"
)
with (
patch(
"google.genai.models.AsyncModels.generate_content",
return_value=Mock(
text=stubbed_generated_content,
prompt_feedback=None,
candidates=[Mock()],
),
) as mock_generate,
patch("pathlib.Path.exists", return_value=True),
patch.object(hass.config, "is_allowed_path", return_value=True),
patch("builtins.open", mock_open(read_data="this is an image")),
patch("mimetypes.guess_type", return_value=["image/jpeg"]),
patch(
"google.genai.files.Files.upload",
side_effect=[
File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE),
File(name="context.txt", state=FileState.PROCESSING),
],
),
patch(
"google.genai.files.AsyncFiles.get",
side_effect=[
File(name="context.txt", state=FileState.PROCESSING),
File(name="context.txt", state=FileState.ACTIVE),
],
),
):
response = await hass.services.async_call(
"google_generative_ai_conversation",
"generate_content",
{
"prompt": "Describe this image from my doorbell camera",
"filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"],
},
blocking=True,
return_response=True,
)
assert response == {
"text": stubbed_generated_content,
}
assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot
@pytest.mark.usefixtures("mock_init_component")
async def test_generate_content_file_processing_fails(
hass: HomeAssistant, snapshot: SnapshotAssertion
) -> None:
"""Test generate content service."""
stubbed_generated_content = (
"A mail carrier is at your front door delivering a package"
)
with (
patch(
"google.genai.models.AsyncModels.generate_content",
return_value=Mock(
text=stubbed_generated_content,
prompt_feedback=None,
candidates=[Mock()],
),
),
patch("pathlib.Path.exists", return_value=True),
patch.object(hass.config, "is_allowed_path", return_value=True),
patch("builtins.open", mock_open(read_data="this is an image")),
patch("mimetypes.guess_type", return_value=["image/jpeg"]),
patch(
"google.genai.files.Files.upload",
side_effect=[
File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE),
File(name="context.txt", state=FileState.PROCESSING),
],
),
patch(
"google.genai.files.AsyncFiles.get",
side_effect=[
File(name="context.txt", state=FileState.PROCESSING),
File(
name="context.txt",
state=FileState.FAILED,
error={"message": "File processing failed"},
),
],
),
pytest.raises(
HomeAssistantError,
match="File `context.txt` processing failed, reason: File processing failed",
),
):
await hass.services.async_call(
"google_generative_ai_conversation",
"generate_content",
{
"prompt": "Describe this image from my doorbell camera",
"filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"],
},
blocking=True,
return_response=True,
)
@pytest.mark.usefixtures("mock_init_component")
async def test_generate_content_service_error(
hass: HomeAssistant,
+43
View File
@@ -269,6 +269,49 @@ async def test_ingress_request_options(
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
@pytest.mark.parametrize(
"build_type",
[
("a3_vl", "test/beer/ping?index=1"),
("core", "index.html"),
("local", "panel/config"),
("jk_921", "editor.php?idx=3&ping=5"),
("fsadjf10312", ""),
],
)
async def test_ingress_request_head(
hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test no auth needed for ."""
aioclient_mock.head(
f"http://127.0.0.1/ingress/{build_type[0]}/{build_type[1]}",
text="test",
)
resp = await hassio_noauth_client.head(
f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}",
headers={"X-Test-Header": "beer"},
)
# Check we got right response
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert body == "" # head does not return a body
# Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1
assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3]
assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress"
assert (
aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"]
== f"/api/hassio_ingress/{build_type[0]}"
)
assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
@pytest.mark.parametrize(
"build_type",
[
@@ -3,8 +3,8 @@
from unittest.mock import Mock, patch
from homematicip.auth import Auth
from homematicip.base.base_connection import HmipConnectionError
from homematicip.connection.connection_context import ConnectionContext
from homematicip.exceptions.connection_exceptions import HmipConnectionError
import pytest
from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
@@ -2,8 +2,8 @@
from unittest.mock import AsyncMock, Mock, patch
from homematicip.base.base_connection import HmipConnectionError
from homematicip.connection.connection_context import ConnectionContext
from homematicip.exceptions.connection_exceptions import HmipConnectionError
from homeassistant.components.homematicip_cloud.const import (
CONF_ACCESSPOINT,
+113 -22
View File
@@ -2,8 +2,10 @@
import pytest
from homeassistant.components.cover import SERVICE_OPEN_COVER
from homeassistant.components.lock import SERVICE_LOCK
from homeassistant.components.button import SERVICE_PRESS
from homeassistant.components.cover import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER
from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK
from homeassistant.components.valve import SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_FRIENDLY_NAME,
@@ -121,41 +123,130 @@ async def test_turn_on_intent(hass: HomeAssistant) -> None:
assert call.data == {"entity_id": ["light.test_light"]}
async def test_translated_turn_on_intent(
@pytest.mark.parametrize("domain", ["button", "input_button"])
async def test_turn_on_intent_button(
hass: HomeAssistant, entity_registry: er.EntityRegistry, domain
) -> None:
"""Test HassTurnOn intent on button domains."""
assert await async_setup_component(hass, "intent", {})
button = entity_registry.async_get_or_create(domain, "test", "button_uid")
hass.states.async_set(button.entity_id, "unknown")
button_service_calls = async_mock_service(hass, domain, SERVICE_PRESS)
with pytest.raises(intent.IntentHandleError):
await intent.async_handle(
hass, "test", "HassTurnOff", {"name": {"value": button.entity_id}}
)
await intent.async_handle(
hass, "test", "HassTurnOn", {"name": {"value": button.entity_id}}
)
assert len(button_service_calls) == 1
call = button_service_calls[0]
assert call.domain == domain
assert call.service == SERVICE_PRESS
assert call.data == {"entity_id": button.entity_id}
async def test_turn_on_off_intent_valve(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test HassTurnOn intent on domains which don't have the intent."""
result = await async_setup_component(hass, "homeassistant", {})
result = await async_setup_component(hass, "intent", {})
await hass.async_block_till_done()
assert result
"""Test HassTurnOn/Off intent on valve domains."""
assert await async_setup_component(hass, "intent", {})
valve = entity_registry.async_get_or_create("valve", "test", "valve_uid")
hass.states.async_set(valve.entity_id, "closed")
open_calls = async_mock_service(hass, "valve", SERVICE_OPEN_VALVE)
close_calls = async_mock_service(hass, "valve", SERVICE_CLOSE_VALVE)
await intent.async_handle(
hass, "test", "HassTurnOn", {"name": {"value": valve.entity_id}}
)
assert len(open_calls) == 1
call = open_calls[0]
assert call.domain == "valve"
assert call.service == SERVICE_OPEN_VALVE
assert call.data == {"entity_id": valve.entity_id}
await intent.async_handle(
hass, "test", "HassTurnOff", {"name": {"value": valve.entity_id}}
)
assert len(close_calls) == 1
call = close_calls[0]
assert call.domain == "valve"
assert call.service == SERVICE_CLOSE_VALVE
assert call.data == {"entity_id": valve.entity_id}
async def test_turn_on_off_intent_cover(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test HassTurnOn/Off intent on cover domains."""
assert await async_setup_component(hass, "intent", {})
cover = entity_registry.async_get_or_create("cover", "test", "cover_uid")
lock = entity_registry.async_get_or_create("lock", "test", "lock_uid")
hass.states.async_set(cover.entity_id, "closed")
hass.states.async_set(lock.entity_id, "unlocked")
cover_service_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
lock_service_calls = async_mock_service(hass, "lock", SERVICE_LOCK)
open_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
close_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER)
await intent.async_handle(
hass, "test", "HassTurnOn", {"name": {"value": cover.entity_id}}
)
assert len(open_calls) == 1
call = open_calls[0]
assert call.domain == "cover"
assert call.service == SERVICE_OPEN_COVER
assert call.data == {"entity_id": cover.entity_id}
await intent.async_handle(
hass, "test", "HassTurnOff", {"name": {"value": cover.entity_id}}
)
assert len(close_calls) == 1
call = close_calls[0]
assert call.domain == "cover"
assert call.service == SERVICE_CLOSE_COVER
assert call.data == {"entity_id": cover.entity_id}
async def test_turn_on_off_intent_lock(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test HassTurnOn/Off intent on lock domains."""
assert await async_setup_component(hass, "intent", {})
lock = entity_registry.async_get_or_create("lock", "test", "lock_uid")
hass.states.async_set(lock.entity_id, "locked")
unlock_calls = async_mock_service(hass, "lock", SERVICE_UNLOCK)
lock_calls = async_mock_service(hass, "lock", SERVICE_LOCK)
await intent.async_handle(
hass, "test", "HassTurnOn", {"name": {"value": lock.entity_id}}
)
await hass.async_block_till_done()
assert len(cover_service_calls) == 1
call = cover_service_calls[0]
assert call.domain == "cover"
assert call.service == "open_cover"
assert call.data == {"entity_id": cover.entity_id}
assert len(lock_service_calls) == 1
call = lock_service_calls[0]
assert len(lock_calls) == 1
call = lock_calls[0]
assert call.domain == "lock"
assert call.service == "lock"
assert call.service == SERVICE_LOCK
assert call.data == {"entity_id": lock.entity_id}
await intent.async_handle(
hass, "test", "HassTurnOff", {"name": {"value": lock.entity_id}}
)
assert len(unlock_calls) == 1
call = unlock_calls[0]
assert call.domain == "lock"
assert call.service == SERVICE_UNLOCK
assert call.data == {"entity_id": lock.entity_id}
@@ -104,19 +104,6 @@ async def test_unpause_media_player_intent(hass: HomeAssistant) -> None:
assert call.service == SERVICE_MEDIA_PLAY
assert call.data == {"entity_id": entity_id}
# Test if not paused
hass.states.async_set(
entity_id,
STATE_PLAYING,
)
with pytest.raises(intent.MatchFailedError):
response = await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_MEDIA_UNPAUSE,
)
async def test_next_media_player_intent(hass: HomeAssistant) -> None:
"""Test HassMediaNext intent for media players."""
@@ -245,17 +232,6 @@ async def test_volume_media_player_intent(hass: HomeAssistant) -> None:
assert call.service == SERVICE_VOLUME_SET
assert call.data == {"entity_id": entity_id, "volume_level": 0.5}
# Test if not playing
hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes)
with pytest.raises(intent.MatchFailedError):
response = await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_SET_VOLUME,
{"volume_level": {"value": 50}},
)
# Test feature not supported
hass.states.async_set(
entity_id,
+5
View File
@@ -87,6 +87,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = {
MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = {
"5269352dd9534c908d22812ea5d714cd": {
"platform": "notify",
"name": None,
"command_topic": "test-topic",
"command_template": "{{ value }}",
"entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd",
@@ -152,6 +153,10 @@ MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = {
"state_topic": "test-topic",
"color_temp_kelvin": True,
"state_value_template": "{{ value_json.value }}",
"brightness_scale": 255,
"max_kelvin": 6535,
"min_kelvin": 2000,
"white_scale": 255,
"entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2",
},
}
+10 -2
View File
@@ -2817,14 +2817,22 @@ async def test_migrate_of_incompatible_config_entry(
},
{"state_topic": "invalid_subscribe_topic"},
),
(
{
"command_topic": "test-topic",
"light_brightness_settings": {
"brightness_command_topic": "test-topic#invalid"
},
},
{"light_brightness_settings": "invalid_publish_topic"},
),
(
{
"command_topic": "test-topic",
"advanced_settings": {"max_kelvin": 2000, "min_kelvin": 2000},
},
{
"max_kelvin": "max_below_min_kelvin",
"min_kelvin": "max_below_min_kelvin",
"advanced_settings": "max_below_min_kelvin",
},
),
),
@@ -0,0 +1,19 @@
# serializer version: 1
# name: test_calendar_examples[office365_invalid_tzid]
list([
dict({
'description': None,
'end': dict({
'dateTime': '2024-04-26T15:00:00-06:00',
}),
'location': '',
'recurrence_id': None,
'rrule': None,
'start': dict({
'dateTime': '2024-04-26T14:00:00-06:00',
}),
'summary': 'Uffe',
'uid': '040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000010000000309AE93C8C3A94489F90ADBEA30C2F2B',
}),
])
# ---
@@ -1,11 +1,13 @@
"""Tests for calendar platform of Remote Calendar."""
from datetime import datetime
import pathlib
import textwrap
from httpx import Response
import pytest
import respx
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
@@ -21,6 +23,13 @@ from .conftest import (
from tests.common import MockConfigEntry
# Test data files with known calendars from various sources. You can add a new file
# in the testdata directory and add it will be parsed and tested.
TESTDATA_FILES = sorted(
pathlib.Path("tests/components/remote_calendar/testdata/").glob("*.ics")
)
TESTDATA_IDS = [f.stem for f in TESTDATA_FILES]
@respx.mock
async def test_empty_calendar(
@@ -392,3 +401,24 @@ async def test_all_day_iter_order(
events = await get_events("2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z")
assert [event["summary"] for event in events] == event_order
@respx.mock
@pytest.mark.parametrize("ics_filename", TESTDATA_FILES, ids=TESTDATA_IDS)
async def test_calendar_examples(
hass: HomeAssistant,
config_entry: MockConfigEntry,
get_events: GetEventsFn,
ics_filename: pathlib.Path,
snapshot: SnapshotAssertion,
) -> None:
"""Test parsing known calendars form test data files."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_filename.read_text(),
)
)
await setup_integration(hass, config_entry)
events = await get_events("1997-07-14T00:00:00", "2025-07-01T00:00:00")
assert events == snapshot
@@ -0,0 +1,58 @@
BEGIN:VCALENDAR
METHOD:PUBLISH
PRODID:Microsoft Exchange Server 2010
VERSION:2.0
X-WR-CALNAME:Kalender
BEGIN:VTIMEZONE
TZID:W. Europe Standard Time
BEGIN:STANDARD
DTSTART:16010101T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VTIMEZONE
TZID:UTC
BEGIN:STANDARD
DTSTART:16010101T000000
TZOFFSETFROM:+0000
TZOFFSETTO:+0000
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T000000
TZOFFSETFROM:+0000
TZOFFSETTO:+0000
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000
010000000309AE93C8C3A94489F90ADBEA30C2F2B
SUMMARY:Uffe
DTSTART;TZID=Customized Time Zone:20240426T140000
DTEND;TZID=Customized Time Zone:20240426T150000
CLASS:PUBLIC
PRIORITY:5
DTSTAMP:20250417T155647Z
TRANSP:OPAQUE
STATUS:CONFIRMED
SEQUENCE:0
LOCATION:
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INSTTYPE:0
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
X-MICROSOFT-DISALLOW-COUNTER:FALSE
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
END:VEVENT
END:VCALENDAR
@@ -1005,102 +1005,6 @@
'state': 'off',
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_driver_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_twingo_iii_driver_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Driver door',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'driver_door_status',
'unique_id': 'vf1twingoiiivin_driver_door_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_driver_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'REG-TWINGO-III Driver door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_twingo_iii_driver_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_hatch-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_twingo_iii_hatch',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Hatch',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'hatch_status',
'unique_id': 'vf1twingoiiivin_hatch_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_hatch-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'REG-TWINGO-III Hatch',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_twingo_iii_hatch',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_hvac-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1148,102 +1052,6 @@
'state': 'off',
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_twingo_iii_lock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.LOCK: 'lock'>,
'original_icon': None,
'original_name': 'Lock',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'vf1twingoiiivin_lock_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_lock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'lock',
'friendly_name': 'REG-TWINGO-III Lock',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_twingo_iii_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_passenger_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_twingo_iii_passenger_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Passenger door',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'passenger_door_status',
'unique_id': 'vf1twingoiiivin_passenger_door_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_passenger_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'REG-TWINGO-III Passenger door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_twingo_iii_passenger_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_plug-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1292,102 +1100,6 @@
'state': 'on',
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_rear_left_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_twingo_iii_rear_left_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Rear left door',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'rear_left_door_status',
'unique_id': 'vf1twingoiiivin_rear_left_door_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_rear_left_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'REG-TWINGO-III Rear left door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_twingo_iii_rear_left_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_rear_right_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_twingo_iii_rear_right_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Rear right door',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'rear_right_door_status',
'unique_id': 'vf1twingoiiivin_rear_right_door_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_rear_right_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'REG-TWINGO-III Rear right door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_twingo_iii_rear_right_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_charging-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1579,102 +1291,6 @@
'state': 'off',
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_driver_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_zoe_50_driver_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Driver door',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'driver_door_status',
'unique_id': 'vf1zoe50vin_driver_door_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_driver_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'REG-ZOE-50 Driver door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_zoe_50_driver_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_hatch-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_zoe_50_hatch',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Hatch',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'hatch_status',
'unique_id': 'vf1zoe50vin_hatch_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_hatch-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'REG-ZOE-50 Hatch',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_zoe_50_hatch',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_hvac-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1722,102 +1338,6 @@
'state': 'off',
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_zoe_50_lock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.LOCK: 'lock'>,
'original_icon': None,
'original_name': 'Lock',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'vf1zoe50vin_lock_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_lock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'lock',
'friendly_name': 'REG-ZOE-50 Lock',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_zoe_50_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_passenger_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_zoe_50_passenger_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Passenger door',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'passenger_door_status',
'unique_id': 'vf1zoe50vin_passenger_door_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_passenger_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'REG-ZOE-50 Passenger door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_zoe_50_passenger_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_plug-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1866,99 +1386,3 @@
'state': 'off',
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_rear_left_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_zoe_50_rear_left_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Rear left door',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'rear_left_door_status',
'unique_id': 'vf1zoe50vin_rear_left_door_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_rear_left_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'REG-ZOE-50 Rear left door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_zoe_50_rear_left_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_rear_right_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.reg_zoe_50_rear_right_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Rear right door',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'rear_right_door_status',
'unique_id': 'vf1zoe50vin_rear_right_door_status',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_rear_right_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'REG-ZOE-50 Rear right door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.reg_zoe_50_rear_right_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
@@ -3211,100 +3211,6 @@
'state': 'unknown',
})
# ---
# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_remote_engine_start-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.reg_twingo_iii_remote_engine_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Remote engine start',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'res_state',
'unique_id': 'vf1twingoiiivin_res_state',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_remote_engine_start-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'REG-TWINGO-III Remote engine start',
}),
'context': <ANY>,
'entity_id': 'sensor.reg_twingo_iii_remote_engine_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_remote_engine_start_code-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.reg_twingo_iii_remote_engine_start_code',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Remote engine start code',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'res_state_code',
'unique_id': 'vf1twingoiiivin_res_state_code',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_remote_engine_start_code-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'REG-TWINGO-III Remote engine start code',
}),
'context': <ANY>,
'entity_id': 'sensor.reg_twingo_iii_remote_engine_start_code',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -4737,97 +4643,3 @@
'state': 'unplugged',
})
# ---
# name: test_sensors[zoe_50][sensor.reg_zoe_50_remote_engine_start-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.reg_zoe_50_remote_engine_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Remote engine start',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'res_state',
'unique_id': 'vf1zoe50vin_res_state',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[zoe_50][sensor.reg_zoe_50_remote_engine_start-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'REG-ZOE-50 Remote engine start',
}),
'context': <ANY>,
'entity_id': 'sensor.reg_zoe_50_remote_engine_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Stopped, ready for RES',
})
# ---
# name: test_sensors[zoe_50][sensor.reg_zoe_50_remote_engine_start_code-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.reg_zoe_50_remote_engine_start_code',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Remote engine start code',
'platform': 'renault',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'res_state_code',
'unique_id': 'vf1zoe50vin_res_state_code',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[zoe_50][sensor.reg_zoe_50_remote_engine_start_code-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'REG-ZOE-50 Remote engine start code',
}),
'context': <ANY>,
'entity_id': 'sensor.reg_zoe_50_remote_engine_start_code',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10',
})
# ---
+2 -2
View File
@@ -197,7 +197,7 @@ async def test_sensor_throttling_after_init(
@pytest.mark.parametrize(
("vehicle_type", "vehicle_count", "scan_interval"),
[
("zoe_50", 1, 420), # 7 coordinators => 7 minutes interval
("zoe_50", 1, 300), # 5 coordinators => 5 minutes interval
("captur_fuel", 1, 240), # 4 coordinators => 4 minutes interval
("multi", 2, 480), # 8 coordinators => 8 minutes interval
],
@@ -236,7 +236,7 @@ async def test_dynamic_scan_interval(
@pytest.mark.parametrize(
("vehicle_type", "vehicle_count", "scan_interval"),
[
("zoe_50", 1, 300), # (7-2) coordinators => 5 minutes interval
("zoe_50", 1, 240), # (6-2) coordinators => 4 minutes interval
("captur_fuel", 1, 180), # (4-1) coordinators => 3 minutes interval
("multi", 2, 360), # (8-2) coordinators => 6 minutes interval
],
@@ -78,6 +78,7 @@ async def test_browsing(hass: HomeAssistant, setup: str) -> None:
assert item_child.children is None
assert item_child.can_play is False
assert item_child.can_expand is True
assert item_child.thumbnail == "https://brands.home-assistant.io/_/test/logo.png"
item_child = await media_source.async_browse_media(
hass, item.children[0].media_content_id + "?message=bla"
+78 -93
View File
@@ -335,9 +335,8 @@ async def test_pipeline(
patch.object(satellite, "tts_response_finished", tts_response_finished),
):
satellite._tones = Tones(0)
satellite.transport = Mock()
satellite.connection_made(Mock())
satellite.connection_made(satellite.transport)
assert satellite.state == AssistSatelliteState.IDLE
# Ensure audio queue is cleared before pipeline starts
@@ -473,7 +472,7 @@ async def test_tts_timeout(
for tone in Tones:
satellite._tone_bytes[tone] = tone_bytes
satellite.transport = Mock()
satellite.connection_made(Mock())
satellite.send_audio = Mock()
original_send_tts = satellite._send_tts
@@ -511,6 +510,7 @@ async def test_tts_wrong_extension(
assert await async_setup_component(hass, "voip", {})
satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id)
satellite.addr = ("192.168.1.1", 12345)
assert isinstance(satellite, VoipAssistSatellite)
done = asyncio.Event()
@@ -559,8 +559,6 @@ async def test_tts_wrong_extension(
"homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream",
new=async_pipeline_from_audio_stream,
):
satellite.transport = Mock()
original_send_tts = satellite._send_tts
async def send_tts(*args, **kwargs):
@@ -572,6 +570,8 @@ async def test_tts_wrong_extension(
satellite._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign]
satellite.connection_made(Mock())
# silence
satellite.on_chunk(bytes(_ONE_SECOND))
@@ -579,10 +579,18 @@ async def test_tts_wrong_extension(
satellite.on_chunk(bytes([255] * _ONE_SECOND * 2))
# silence (assumes relaxed VAD sensitivity)
satellite.on_chunk(bytes(_ONE_SECOND * 4))
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
# Wait for mock pipeline to exhaust the audio stream
async with asyncio.timeout(1):
async with asyncio.timeout(3):
await done.wait()
@@ -595,6 +603,7 @@ async def test_tts_wrong_wav_format(
assert await async_setup_component(hass, "voip", {})
satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id)
satellite.addr = ("192.168.1.1", 12345)
assert isinstance(satellite, VoipAssistSatellite)
done = asyncio.Event()
@@ -643,8 +652,6 @@ async def test_tts_wrong_wav_format(
"homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream",
new=async_pipeline_from_audio_stream,
):
satellite.transport = Mock()
original_send_tts = satellite._send_tts
async def send_tts(*args, **kwargs):
@@ -656,6 +663,8 @@ async def test_tts_wrong_wav_format(
satellite._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign]
satellite.connection_made(Mock())
# silence
satellite.on_chunk(bytes(_ONE_SECOND))
@@ -663,10 +672,18 @@ async def test_tts_wrong_wav_format(
satellite.on_chunk(bytes([255] * _ONE_SECOND * 2))
# silence (assumes relaxed VAD sensitivity)
satellite.on_chunk(bytes(_ONE_SECOND * 4))
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
# Wait for mock pipeline to exhaust the audio stream
async with asyncio.timeout(1):
async with asyncio.timeout(3):
await done.wait()
@@ -679,6 +696,7 @@ async def test_empty_tts_output(
assert await async_setup_component(hass, "voip", {})
satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id)
satellite.addr = ("192.168.1.1", 12345)
assert isinstance(satellite, VoipAssistSatellite)
async def async_pipeline_from_audio_stream(*args, **kwargs):
@@ -728,7 +746,7 @@ async def test_empty_tts_output(
"homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts",
) as mock_send_tts,
):
satellite.transport = Mock()
satellite.connection_made(Mock())
# silence
satellite.on_chunk(bytes(_ONE_SECOND))
@@ -737,10 +755,18 @@ async def test_empty_tts_output(
satellite.on_chunk(bytes([255] * _ONE_SECOND * 2))
# silence (assumes relaxed VAD sensitivity)
satellite.on_chunk(bytes(_ONE_SECOND * 4))
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
# Wait for mock pipeline to finish
async with asyncio.timeout(1):
async with asyncio.timeout(2):
await satellite._tts_done.wait()
mock_send_tts.assert_not_called()
@@ -785,7 +811,7 @@ async def test_pipeline_error(
),
):
satellite._tones = Tones.ERROR
satellite.transport = Mock()
satellite.connection_made(Mock())
satellite._async_send_audio = AsyncMock(side_effect=async_send_audio) # type: ignore[method-assign]
satellite.on_chunk(bytes(_ONE_SECOND))
@@ -845,16 +871,20 @@ async def test_announce(
"homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts",
) as mock_send_tts,
):
satellite.transport = Mock()
announce_task = hass.async_create_background_task(
satellite.async_announce(announcement), "voip_announce"
)
await asyncio.sleep(0)
satellite.connection_made(Mock())
mock_protocol.outgoing_call.assert_called_once()
# Trigger announcement
satellite.on_chunk(bytes(_ONE_SECOND))
async with asyncio.timeout(1):
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
async with asyncio.timeout(2):
await announce_task
mock_send_tts.assert_called_once_with(
@@ -897,11 +927,11 @@ async def test_voip_id_is_ip_address(
"homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts",
) as mock_send_tts,
):
satellite.transport = Mock()
announce_task = hass.async_create_background_task(
satellite.async_announce(announcement), "voip_announce"
)
await asyncio.sleep(0)
satellite.connection_made(Mock())
mock_protocol.outgoing_call.assert_called_once()
assert (
mock_protocol.outgoing_call.call_args.kwargs["destination"].host
@@ -910,7 +940,11 @@ async def test_voip_id_is_ip_address(
# Trigger announcement
satellite.on_chunk(bytes(_ONE_SECOND))
async with asyncio.timeout(1):
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
async with asyncio.timeout(2):
await announce_task
mock_send_tts.assert_called_once_with(
@@ -955,7 +989,7 @@ async def test_announce_timeout(
0.01,
),
):
satellite.transport = Mock()
satellite.connection_made(Mock())
with pytest.raises(TimeoutError):
await satellite.async_announce(announcement)
@@ -1042,7 +1076,7 @@ async def test_start_conversation(
new=async_pipeline_from_audio_stream,
),
):
satellite.transport = Mock()
satellite.connection_made(Mock())
conversation_task = hass.async_create_background_task(
satellite.async_start_conversation(announcement), "voip_start_conversation"
)
@@ -1051,16 +1085,20 @@ async def test_start_conversation(
# Trigger announcement and wait for it to finish
satellite.on_chunk(bytes(_ONE_SECOND))
async with asyncio.timeout(1):
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
async with asyncio.timeout(2):
await tts_sent.wait()
tts_sent.clear()
# Trigger pipeline
satellite.on_chunk(bytes(_ONE_SECOND))
async with asyncio.timeout(1):
# Wait for TTS
await tts_sent.wait()
await asyncio.sleep(0.2)
satellite.on_chunk(bytes(_ONE_SECOND))
await asyncio.sleep(3)
async with asyncio.timeout(3):
# Wait for Conversation end
await conversation_task
@@ -1073,21 +1111,8 @@ async def test_start_conversation_user_doesnt_pick_up(
"""Test start conversation when the user doesn't pick up."""
assert await async_setup_component(hass, "voip", {})
pipeline = assist_pipeline.Pipeline(
conversation_engine="test engine",
conversation_language="en",
language="en",
name="test pipeline",
stt_engine="test stt",
stt_language="en",
tts_engine="test tts",
tts_language="en",
tts_voice=None,
wake_word_entity=None,
wake_word_id=None,
)
satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id)
satellite.addr = ("192.168.1.1", 12345)
assert isinstance(satellite, VoipAssistSatellite)
assert (
satellite.supported_features
@@ -1098,62 +1123,22 @@ async def test_start_conversation_user_doesnt_pick_up(
mock_protocol: AsyncMock = hass.data[DOMAIN].protocol
mock_protocol.outgoing_call = Mock()
pipeline_started = asyncio.Event()
async def async_pipeline_from_audio_stream(
hass: HomeAssistant,
context: Context,
*args,
conversation_extra_system_prompt: str | None = None,
**kwargs,
):
# System prompt should be not be set due to timeout (user not picking up)
assert conversation_extra_system_prompt is None
pipeline_started.set()
announcement = assist_satellite.AssistSatelliteAnnouncement(
message="test announcement",
media_id=_MEDIA_ID,
tts_token="test-token",
original_media_id=_MEDIA_ID,
media_id_source="tts",
)
# Very short timeout which will trigger because we don't send any audio in
with (
patch(
"homeassistant.components.assist_satellite.entity.async_get_pipeline",
return_value=pipeline,
),
patch(
"homeassistant.components.voip.assist_satellite.VoipAssistSatellite.async_start_conversation",
side_effect=TimeoutError,
),
patch(
"homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream",
new=async_pipeline_from_audio_stream,
),
patch(
"homeassistant.components.tts.generate_media_source_id",
return_value="media-source://bla",
),
patch(
"homeassistant.components.tts.async_resolve_engine",
return_value="test tts",
),
patch(
"homeassistant.components.tts.async_create_stream",
return_value=MockResultStream(hass, "wav", b""),
"homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT",
0.1,
),
):
satellite.transport = Mock()
satellite.connection_made(Mock())
# Error should clear system prompt
with pytest.raises(TimeoutError):
await hass.services.async_call(
assist_satellite.DOMAIN,
"start_conversation",
{
"entity_id": satellite.entity_id,
"start_message": "test announcement",
"extra_system_prompt": "test prompt",
},
blocking=True,
)
# Trigger a pipeline so we can check if the system prompt was cleared
satellite.on_chunk(bytes(_ONE_SECOND))
async with asyncio.timeout(1):
await pipeline_started.wait()
await satellite.async_start_conversation(announcement)
+332 -18
View File
@@ -190,6 +190,19 @@ def mock_sdk_version(client: MagicMock) -> Generator[None]:
client.driver.controller.data["sdkVersion"] = original_sdk_version
@pytest.fixture(name="driver_ready_timeout")
def mock_driver_ready_timeout() -> Generator[None]:
"""Mock migration nvm restore driver ready timeout."""
with patch(
(
"homeassistant.components.zwave_js.config_flow."
"RESTORE_NVM_DRIVER_READY_TIMEOUT"
),
new=0,
):
yield
async def test_manual(hass: HomeAssistant) -> None:
"""Test we create an entry with manual step."""
@@ -653,6 +666,7 @@ async def test_usb_discovery(
install_addon,
addon_options,
get_addon_discovery_info,
mock_usb_serial_by_id: MagicMock,
set_addon_options,
start_addon,
usb_discovery_info: UsbServiceInfo,
@@ -668,6 +682,7 @@ async def test_usb_discovery(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usb_confirm"
assert result["description_placeholders"] == {"name": discovery_name}
assert mock_usb_serial_by_id.call_count == 1
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
@@ -682,7 +697,7 @@ async def test_usb_discovery(
assert install_addon.call_args == call("core_zwave_js")
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -765,6 +780,7 @@ async def test_usb_discovery_addon_not_running(
supervisor,
addon_installed,
addon_options,
mock_usb_serial_by_id: MagicMock,
set_addon_options,
start_addon,
get_addon_discovery_info,
@@ -779,11 +795,12 @@ async def test_usb_discovery_addon_not_running(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usb_confirm"
assert mock_usb_serial_by_id.call_count == 2
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_user"
# Make sure the discovered usb device is preferred.
data_schema = result["data_schema"]
@@ -876,6 +893,7 @@ async def test_usb_discovery_addon_not_running(
async def test_usb_discovery_migration(
hass: HomeAssistant,
addon_options: dict[str, Any],
mock_usb_serial_by_id: MagicMock,
set_addon_options: AsyncMock,
restart_addon: AsyncMock,
client: MagicMock,
@@ -884,6 +902,144 @@ async def test_usb_discovery_migration(
"""Test usb discovery migration."""
addon_options["device"] = "/dev/ttyUSB0"
entry = integration
assert client.connect.call_count == 1
hass.config_entries.async_update_entry(
entry,
unique_id="1234",
data={
"url": "ws://localhost:3000",
"use_addon": True,
"usb_path": "/dev/ttyUSB0",
},
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm backup progress", {"bytesRead": 100, "total": 200}
)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
async def mock_restore_nvm(data: bytes):
client.driver.controller.emit(
"nvm convert progress",
{"event": "nvm convert progress", "bytesRead": 100, "total": 200},
)
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm restore progress",
{"event": "nvm restore progress", "bytesWritten": 100, "total": 200},
)
client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"}
)
client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm)
events = async_capture_events(
hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USB},
data=USB_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usb_confirm"
assert mock_usb_serial_by_id.call_count == 2
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
assert len(events) == 1
assert events[0].data["progress"] == 0.5
events.clear()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
assert set_addon_options.call_args == call(
"core_zwave_js", AddonsOptions(config={"device": USB_DISCOVERY_INFO.device})
)
await hass.async_block_till_done()
assert restart_addon.call_args == call("core_zwave_js")
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "restore_nvm"
assert client.connect.call_count == 2
await hass.async_block_till_done()
assert client.connect.call_count == 3
assert entry.state is config_entries.ConfigEntryState.LOADED
assert client.driver.controller.async_restore_nvm.call_count == 1
assert len(events) == 2
assert events[0].data["progress"] == 0.25
assert events[1].data["progress"] == 0.75
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "migration_successful"
assert integration.data["url"] == "ws://host1:3001"
assert integration.data["usb_path"] == USB_DISCOVERY_INFO.device
assert integration.data["use_addon"] is True
@pytest.mark.usefixtures("supervisor", "addon_running", "get_addon_discovery_info")
@pytest.mark.parametrize(
"discovery_info",
[
[
Discovery(
addon="core_zwave_js",
service="zwave_js",
uuid=uuid4(),
config=ADDON_DISCOVERY_INFO,
)
]
],
)
async def test_usb_discovery_migration_driver_ready_timeout(
hass: HomeAssistant,
addon_options: dict[str, Any],
driver_ready_timeout: None,
mock_usb_serial_by_id: MagicMock,
set_addon_options: AsyncMock,
restart_addon: AsyncMock,
client: MagicMock,
integration: MockConfigEntry,
) -> None:
"""Test driver ready timeout after nvm restore during usb discovery migration."""
addon_options["device"] = "/dev/ttyUSB0"
entry = integration
assert client.connect.call_count == 1
hass.config_entries.async_update_entry(
entry,
unique_id="1234",
@@ -929,6 +1085,7 @@ async def test_usb_discovery_migration(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usb_confirm"
assert mock_usb_serial_by_id.call_count == 2
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
@@ -952,10 +1109,10 @@ async def test_usb_discovery_migration(
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
assert set_addon_options.call_args == call(
@@ -970,8 +1127,10 @@ async def test_usb_discovery_migration(
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "restore_nvm"
assert client.connect.call_count == 2
await hass.async_block_till_done()
assert client.connect.call_count == 3
assert entry.state is config_entries.ConfigEntryState.LOADED
assert client.driver.controller.async_restore_nvm.call_count == 1
assert len(events) == 2
@@ -1015,7 +1174,7 @@ async def test_discovery_addon_not_running(
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -1117,7 +1276,7 @@ async def test_discovery_addon_not_installed(
assert install_addon.call_args == call("core_zwave_js")
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -1278,6 +1437,7 @@ async def test_abort_usb_discovery_addon_required(
async def test_abort_usb_discovery_confirm_addon_required(
hass: HomeAssistant,
addon_options: dict[str, Any],
mock_usb_serial_by_id: MagicMock,
) -> None:
"""Test usb discovery confirm aborted when existing entry not using add-on."""
addon_options["device"] = "/dev/another_device"
@@ -1301,6 +1461,7 @@ async def test_abort_usb_discovery_confirm_addon_required(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usb_confirm"
assert mock_usb_serial_by_id.call_count == 2
hass.config_entries.async_update_entry(
entry,
@@ -1331,6 +1492,7 @@ async def test_usb_discovery_requires_supervisor(hass: HomeAssistant) -> None:
async def test_usb_discovery_same_device(
hass: HomeAssistant,
addon_options: dict[str, Any],
mock_usb_serial_by_id: MagicMock,
) -> None:
"""Test usb discovery flow is aborted when the add-on device is discovered."""
addon_options["device"] = USB_DISCOVERY_INFO.device
@@ -1341,6 +1503,7 @@ async def test_usb_discovery_same_device(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_usb_serial_by_id.call_count == 2
@pytest.mark.parametrize(
@@ -1674,7 +1837,7 @@ async def test_addon_installed(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -1777,7 +1940,7 @@ async def test_addon_installed_start_failure(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -1862,7 +2025,7 @@ async def test_addon_installed_failures(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -1943,7 +2106,7 @@ async def test_addon_installed_set_options_failure(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -2058,7 +2221,7 @@ async def test_addon_installed_already_configured(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -2154,7 +2317,7 @@ async def test_addon_not_installed(
assert install_addon.call_args == call("core_zwave_js")
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -2600,7 +2763,7 @@ async def test_reconfigure_addon_running(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -2735,7 +2898,7 @@ async def test_reconfigure_addon_running_no_changes(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -2916,7 +3079,7 @@ async def test_reconfigure_different_device(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -3099,7 +3262,7 @@ async def test_reconfigure_addon_restart_failed(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -3240,7 +3403,7 @@ async def test_reconfigure_addon_running_server_info_failure(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -3387,7 +3550,7 @@ async def test_reconfigure_addon_not_installed(
assert install_addon.call_args == call("core_zwave_js")
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon"
assert result["step_id"] == "configure_addon_reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -3542,6 +3705,152 @@ async def test_reconfigure_migrate_with_addon(
) -> None:
"""Test migration flow with add-on."""
entry = integration
assert client.connect.call_count == 1
hass.config_entries.async_update_entry(
entry,
unique_id="1234",
data={
"url": "ws://localhost:3000",
"use_addon": True,
"usb_path": "/dev/ttyUSB0",
},
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm backup progress", {"bytesRead": 100, "total": 200}
)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
async def mock_restore_nvm(data: bytes):
client.driver.controller.emit(
"nvm convert progress",
{"event": "nvm convert progress", "bytesRead": 100, "total": 200},
)
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm restore progress",
{"event": "nvm restore progress", "bytesWritten": 100, "total": 200},
)
client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"}
)
client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm)
events = async_capture_events(
hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE
)
result = await entry.start_reconfigure_flow(hass)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
assert len(events) == 1
assert events[0].data["progress"] == 0.5
events.clear()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "choose_serial_port"
assert result["data_schema"].schema[CONF_USB_PATH]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_USB_PATH: "/test",
},
)
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
assert set_addon_options.call_args == call(
"core_zwave_js", AddonsOptions(config={"device": "/test"})
)
await hass.async_block_till_done()
assert restart_addon.call_args == call("core_zwave_js")
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "restore_nvm"
assert client.connect.call_count == 2
await hass.async_block_till_done()
assert client.connect.call_count == 3
assert entry.state is config_entries.ConfigEntryState.LOADED
assert client.driver.controller.async_restore_nvm.call_count == 1
assert len(events) == 2
assert events[0].data["progress"] == 0.25
assert events[1].data["progress"] == 0.75
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "migration_successful"
assert integration.data["url"] == "ws://host1:3001"
assert integration.data["usb_path"] == "/test"
assert integration.data["use_addon"] is True
@pytest.mark.parametrize(
"discovery_info",
[
[
Discovery(
addon="core_zwave_js",
service="zwave_js",
uuid=uuid4(),
config=ADDON_DISCOVERY_INFO,
)
]
],
)
async def test_reconfigure_migrate_driver_ready_timeout(
hass: HomeAssistant,
client,
supervisor,
integration,
addon_running,
driver_ready_timeout: None,
restart_addon,
set_addon_options,
get_addon_discovery_info,
) -> None:
"""Test migration flow with driver ready timeout after nvm restore."""
entry = integration
assert client.connect.call_count == 1
hass.config_entries.async_update_entry(
entry,
unique_id="1234",
@@ -3609,6 +3918,7 @@ async def test_reconfigure_migrate_with_addon(
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
@@ -3623,7 +3933,6 @@ async def test_reconfigure_migrate_with_addon(
},
)
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
assert set_addon_options.call_args == call(
@@ -3638,8 +3947,10 @@ async def test_reconfigure_migrate_with_addon(
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "restore_nvm"
assert client.connect.call_count == 2
await hass.async_block_till_done()
assert client.connect.call_count == 3
assert entry.state is config_entries.ConfigEntryState.LOADED
assert client.driver.controller.async_restore_nvm.call_count == 1
assert len(events) == 2
@@ -3797,6 +4108,7 @@ async def test_reconfigure_migrate_start_addon_failure(
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
@@ -3891,6 +4203,7 @@ async def test_reconfigure_migrate_restore_failure(
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
@@ -4056,6 +4369,7 @@ async def test_choose_serial_port_usb_ports_failure(
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
with patch(
"homeassistant.components.zwave_js.config_flow.async_get_usb_ports",
+4
View File
@@ -110,6 +110,10 @@ class AiohttpClientMocker:
"""Register a mock patch request."""
self.request("patch", *args, **kwargs)
def head(self, *args, **kwargs):
"""Register a mock head request."""
self.request("head", *args, **kwargs)
@property
def call_count(self):
"""Return the number of requests made."""