Compare commits

...

61 Commits

Author SHA1 Message Date
Franck Nijhof 16c6bd08f8 Bump version to 2025.7.0b3 2025-06-27 17:55:31 +00:00
Simone Chemelli 18834849c2 Bump aioamazondevices to 3.1.22 (#147681) 2025-06-27 17:54:40 +00:00
hanwg e4d820799f Add codeowner for Telegram bot (#147680) 2025-06-27 17:54:38 +00:00
mkmer 013a35176a Bump aiosomecomfort to 0.0.33 (#147673) 2025-06-27 17:54:37 +00:00
Norbert Rittel 8230557aef Fix sentence-casing and spacing of button in thermopro (#147671) 2025-06-27 17:54:36 +00:00
Paul Bottein 5451063714 Update frontend to 20250627.0 (#147668) 2025-06-27 17:54:35 +00:00
Shay Levy 8cdc7523a4 Fix Shelly entity removal (#147665) 2025-06-27 17:54:33 +00:00
Josef Zweck 77ccfbd3a9 Fix: Unhandled NoneType sessions in jellyfin (#147659) 2025-06-27 17:54:32 +00:00
Josef Zweck 4977ee4998 Bump jellyfin-apiclient-python to 1.11.0 (#147658) 2025-06-27 17:54:31 +00:00
Josef Zweck 5c0f2d37f0 Make jellyfin not single config entry (#147656) 2025-06-27 17:54:29 +00:00
Thomas55555 0b5d2ab8e4 Respect availability of parent class in Husqvarna Automower (#147649) 2025-06-27 17:54:28 +00:00
Brett Adams 47f3bf29dd Fix energy history in Teslemetry (#147646) 2025-06-27 17:54:26 +00:00
Manu 62f7cbb51e Remove dweet.io integration (#147645) 2025-06-27 17:54:25 +00:00
Bernardus Jansen b9e2c5d34c Add previously missing state classes to dsmr sensors (#147633) 2025-06-27 17:54:24 +00:00
Petar Petrov 1829acd0e1 Z-WaveJS config flow: Change keys question (#147518)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-27 17:54:22 +00:00
Franck Nijhof 41b9a7a9a3 Bump version to 2025.7.0b2 2025-06-27 08:08:02 +00:00
Norbert Rittel 9782637ec8 Clarify descriptions of subaru.unlock_specific_door action (#147655) 2025-06-27 08:05:06 +00:00
Manu 6bd6fa65d2 Bump pynecil to v4.1.1 (#147648) 2025-06-27 08:05:05 +00:00
Joost Lekkerkerker 85343a9f53 Make sure Ollama integration migration is clean (#147630) 2025-06-27 08:05:04 +00:00
Joost Lekkerkerker bc607dd013 Make sure Anthropic integration migration is clean (#147629) 2025-06-27 08:05:02 +00:00
Joost Lekkerkerker c2c388e0cc Make sure OpenAI integration migration is clean (#147627) 2025-06-27 08:05:01 +00:00
Joost Lekkerkerker 3fc154e1d7 Make sure Google Generative AI integration migration is clean (#147625) 2025-06-27 08:05:00 +00:00
Jack Powell efb29d024e Add Diagnostics to PlayStation Network (#147607)
* Add Diagnostics support to PlayStation_Network

* Remove unused constant

* minor cleanup

* Redact additional data

* Redact additional data
2025-06-27 08:04:58 +00:00
Michael 263823c92c Fix config schema to make credentials optional in NUT flows (#147593) 2025-06-27 08:04:57 +00:00
hanwg e5e6ed601b Fix Telegram bot yaml import for webhooks containing None value for URL (#147586) 2025-06-27 08:04:56 +00:00
Petar Petrov 28dfc997f3 Do not factory reset old Z-Wave controller during migration (#147576)
* Do not factory reset old Z-Wave controller during migration

* PR comments

* remove obsolete test
2025-06-27 08:04:55 +00:00
puddly f93ab8d519 Allow setup of Zigbee/Thread for ZBT-1 and Yellow without internet access (#147549)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-27 08:04:54 +00:00
Josef Zweck cb359da79e Make entities unavailable when machine is physically off in lamarzocco (#147426) 2025-06-27 08:04:52 +00:00
Franck Nijhof 6a7385590a Bump version to 2025.7.0b1 2025-06-26 18:03:11 +00:00
Joost Lekkerkerker c0ec987b07 Fix meaters not being added after a reload (#147614) 2025-06-26 18:02:49 +00:00
Joost Lekkerkerker 26521f8cc0 Hide Telegram bot proxy URL behind section (#147613)
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
2025-06-26 18:02:48 +00:00
Manu 4df1f702bf Fix asset url in Habitica integration (#147612) 2025-06-26 18:02:46 +00:00
Joost Lekkerkerker c8422c9fb8 Improve explanation on how to get API token in Telegram (#147605) 2025-06-26 18:02:45 +00:00
Luca Angemi f8207a2e0e Remove default icon for wind direction sensor for Buienradar (#147603)
* Fix wind direction state class sensor

* Remove default icon for wind direction sensor
2025-06-26 18:02:44 +00:00
Bram Kragten 9cc75f3458 Update frontend to 20250626.0 (#147601) 2025-06-26 18:02:43 +00:00
Joost Lekkerkerker a233b6b1e3 Add default title to migrated Ollama entry (#147599) 2025-06-26 18:02:42 +00:00
Joost Lekkerkerker c7677b91da Add default title to migrated Claude entry (#147598) 2025-06-26 18:02:40 +00:00
Joost Lekkerkerker 1f57bba9cd Add default conversation name for OpenAI integration (#147597) 2025-06-26 18:02:39 +00:00
Joost Lekkerkerker 4cc10ca2e2 Set Google AI model as device model (#147582)
* Set Google AI model as device model

* fix
2025-06-26 18:02:38 +00:00
Marcel van der Veldt 153e1e43e8 Do not make the favorite button unavailable when no content playing on a Music Assistant player (#147579) 2025-06-26 18:02:36 +00:00
Joost Lekkerkerker 398dd3ae46 Set right model in OpenAI conversation (#147575) 2025-06-26 18:02:35 +00:00
Petar Petrov 17fd850fa6 Hide unnamed paths when selecting a USB Z-Wave adapter (#147571)
* Hide unnamed paths when selecting a USB Z-Wave adapter

* remove pointless sorting
2025-06-26 18:02:34 +00:00
Petar Petrov ae062b230c Remove obsolete routing info when migrating a Z-Wave network (#147568) 2025-06-26 18:02:33 +00:00
Marcel van der Veldt d523f85404 Fix sending commands to Matter vacuum (#147567) 2025-06-26 18:02:31 +00:00
tronikos f28d6582c6 Refactor in Google AI TTS in preparation for STT (#147562) 2025-06-26 18:02:30 +00:00
Petar Petrov 1e81e5990e Bump zwave-js-server-python to 0.65.0 (#147561)
* Bump zwave-js-server-python to 0.65.0

* update tests
2025-06-26 18:02:29 +00:00
tronikos 5fe2e4b6ed Include subentries in Google Generative AI diagnostics (#147558) 2025-06-26 18:02:28 +00:00
tronikos 914bb3aa76 Use default title for migrated Google Generative AI entries (#147551) 2025-06-26 18:02:26 +00:00
Simone Chemelli cfa6746115 Fix unload for Alexa Devices (#147548) 2025-06-26 18:02:25 +00:00
Simone Chemelli 03f9caf3eb Add action exceptions to Alexa Devices (#147546) 2025-06-26 18:02:24 +00:00
Joost Lekkerkerker 6b2aaf3fdb Show current Lametric version if there is no newer version (#147538) 2025-06-26 18:02:23 +00:00
Luca Angemi 2c4ea0d584 Fix wind direction state class sensor for AEMET (#147535) 2025-06-26 18:02:21 +00:00
Anders Peter Fugmann e627811f7a Bump dependency on pyW215 for DLink integration to 0.8.0 (#147534) 2025-06-26 18:02:20 +00:00
Simone Chemelli 150f41641b Improve config flow strings for Alexa Devices (#147523) 2025-06-26 18:02:19 +00:00
Erik Montnemery b9a7371996 Set end date for when allowing unique id collisions in config entries (#147516)
* Set end date for when allowing unique id collisions in config entries

* Update test
2025-06-26 18:02:17 +00:00
tronikos 7d0e99da43 Fixes in Google AI TTS (#147501)
* Fix Google AI not using correct config options after subentries migration

* Fixes in Google AI TTS

* Fix tests by @IvanLH

* Change type name.

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2025-06-26 18:02:16 +00:00
hanwg 71f281cc14 Fix Telegram bot default target when sending messages (#147470)
* handle targets

* updated error message

* validate chat id for single target

* add validation for chat id

* handle empty target

* handle empty target
2025-06-26 18:02:15 +00:00
Renat Sibgatulin aec812a475 Create a new client session for air-Q to fix cookie polution (#147027) 2025-06-26 18:00:50 +00:00
Robin Lintermann d4b548b169 Fixed issue when tests (should) fail in Smarla (#146102)
* Fixed issue when tests (should) fail

* Use usefixture decorator

* Throw ConfigEntryError instead of AuthFailed
2025-06-26 18:00:48 +00:00
Fabio Natanael Kepler a296324c30 Fix playing TTS and local media source over DLNA (#134903)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-26 18:00:47 +00:00
Franck Nijhof cff3d3d6ac Bump version to 2025.7.0b0 2025-06-25 18:51:19 +00:00
123 changed files with 2633 additions and 1481 deletions
Generated
+2
View File
@@ -1553,6 +1553,8 @@ build.json @home-assistant/supervisor
/tests/components/technove/ @Moustachauve
/homeassistant/components/tedee/ @patrickhilker @zweckj
/tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/telegram_bot/ @hanwg
/tests/components/telegram_bot/ @hanwg
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @Petro31 @home-assistant/core
+1 -1
View File
@@ -336,7 +336,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
name="Wind bearing",
native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
device_class=SensorDeviceClass.WIND_DIRECTION,
),
AemetSensorEntityDescription(
+2 -2
View File
@@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator):
name=DOMAIN,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
session = async_get_clientsession(hass)
session = async_create_clientsession(hass)
self.airq = AirQ(
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session
)
@@ -29,5 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.api.close()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
coordinator = entry.runtime_data
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await coordinator.api.close()
return unload_ok
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==3.1.19"]
"requirements": ["aioamazondevices==3.1.22"]
}
@@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -70,6 +71,7 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
entity_description: AmazonNotifyEntityDescription
@alexa_api_call
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
) -> None:
@@ -26,7 +26,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
@@ -1,8 +1,7 @@
{
"common": {
"data_country": "Country code",
"data_code": "One-time password (OTP code)",
"data_description_country": "The country of your Amazon account.",
"data_description_country": "The country where your Amazon account is registered.",
"data_description_username": "The email address of your Amazon account.",
"data_description_password": "The password of your Amazon account.",
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
@@ -12,10 +11,10 @@
"step": {
"user": {
"data": {
"country": "[%key:component::alexa_devices::common::data_country%]",
"country": "[%key:common::config_flow::data::country%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
"code": "[%key:component::alexa_devices::common::data_code%]"
},
"data_description": {
"country": "[%key:component::alexa_devices::common::data_description_country%]",
@@ -71,5 +70,13 @@
"name": "Do not disturb"
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Error connecting: {error}"
},
"cannot_retrieve_data": {
"message": "Error retrieving data: {error}"
}
}
}
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -60,6 +61,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
entity_description: AmazonSwitchEntityDescription
@alexa_api_call
async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method)
@@ -0,0 +1,40 @@
"""Utils for Alexa Devices."""
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .entity import AmazonEntity
def alexa_api_call[_T: AmazonEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
"""Catch Alexa API call exceptions."""
@wraps(func)
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap all command methods."""
try:
await func(self, *args, **kwargs)
except CannotConnect as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except CannotRetrieveData as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data",
translation_placeholders={"error": repr(err)},
) from err
return cmd_wrapper
+14 -1
View File
@@ -17,7 +17,13 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL
from .const import (
CONF_CHAT_MODEL,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
)
PLATFORMS = (Platform.CONVERSATION,)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -117,12 +123,19 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_CONVERSATION_NAME,
options={},
version=2,
)
@@ -168,7 +168,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
key="windazimuth",
translation_key="windazimuth",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
+1 -1
View File
@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyW215"],
"requirements": ["pyW215==0.7.0"]
"requirements": ["pyW215==0.8.0"]
}
+8
View File
@@ -241,6 +241,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="SHORT_POWER_FAILURE_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -249,6 +250,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="LONG_POWER_FAILURE_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -257,6 +259,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SAG_L1_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -265,6 +268,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SAG_L2_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -273,6 +277,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SAG_L3_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -281,6 +286,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SWELL_L1_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -289,6 +295,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SWELL_L2_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -297,6 +304,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SWELL_L3_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -1,79 +0,0 @@
"""Support for sending data to Dweet.io."""
from datetime import timedelta
import logging
import dweepy
import voluptuous as vol
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_NAME,
CONF_WHITELIST,
EVENT_STATE_CHANGED,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
DOMAIN = "dweet"
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_WHITELIST, default=[]): vol.All(
cv.ensure_list, [cv.entity_id]
),
}
)
},
extra=vol.ALLOW_EXTRA,
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Dweet.io component."""
conf = config[DOMAIN]
name = conf.get(CONF_NAME)
whitelist = conf.get(CONF_WHITELIST)
json_body = {}
def dweet_event_listener(event):
"""Listen for new messages on the bus and sends them to Dweet.io."""
state = event.data.get("new_state")
if (
state is None
or state.state in (STATE_UNKNOWN, "")
or state.entity_id not in whitelist
):
return
try:
_state = state_helper.state_as_number(state)
except ValueError:
_state = state.state
json_body[state.attributes.get(ATTR_FRIENDLY_NAME)] = _state
send_data(name, json_body)
hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener)
return True
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def send_data(name, msg):
"""Send the collected data to Dweet.io."""
try:
dweepy.dweet_for(name, msg)
except dweepy.DweepyError:
_LOGGER.error("Error saving data to Dweet.io: %s", msg)
@@ -1,10 +0,0 @@
{
"domain": "dweet",
"name": "dweet.io",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/dweet",
"iot_class": "cloud_polling",
"loggers": ["dweepy"],
"quality_scale": "legacy",
"requirements": ["dweepy==0.3.0"]
}
-124
View File
@@ -1,124 +0,0 @@
"""Support for showing values from Dweet.io."""
from __future__ import annotations
from datetime import timedelta
import json
import logging
import dweepy
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
from homeassistant.const import (
CONF_DEVICE,
CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Dweet.io Sensor"
SCAN_INTERVAL = timedelta(minutes=1)
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_DEVICE): cv.string,
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Dweet sensor."""
name = config.get(CONF_NAME)
device = config.get(CONF_DEVICE)
value_template = config.get(CONF_VALUE_TEMPLATE)
unit = config.get(CONF_UNIT_OF_MEASUREMENT)
try:
content = json.dumps(dweepy.get_latest_dweet_for(device)[0]["content"])
except dweepy.DweepyError:
_LOGGER.error("Device/thing %s could not be found", device)
return
if value_template and value_template.render_with_possible_json_value(content) == "":
_LOGGER.error("%s was not found", value_template)
return
dweet = DweetData(device)
add_entities([DweetSensor(hass, dweet, name, value_template, unit)], True)
class DweetSensor(SensorEntity):
"""Representation of a Dweet sensor."""
def __init__(self, hass, dweet, name, value_template, unit_of_measurement):
"""Initialize the sensor."""
self.hass = hass
self.dweet = dweet
self._name = name
self._value_template = value_template
self._state = None
self._unit_of_measurement = unit_of_measurement
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def native_unit_of_measurement(self):
"""Return the unit the value is expressed in."""
return self._unit_of_measurement
@property
def native_value(self):
"""Return the state."""
return self._state
def update(self) -> None:
"""Get the latest data from REST API."""
self.dweet.update()
if self.dweet.data is None:
self._state = None
else:
values = json.dumps(self.dweet.data[0]["content"])
self._state = self._value_template.render_with_possible_json_value(
values, None
)
class DweetData:
"""The class for handling the data retrieval."""
def __init__(self, device):
"""Initialize the sensor."""
self._device = device
self.data = None
def update(self):
"""Get the latest data from Dweet.io."""
try:
self.data = dweepy.get_latest_dweet_for(self._device)
except dweepy.DweepyError:
_LOGGER.warning("Device %s doesn't contain any data", self._device)
self.data = None
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250625.0"]
"requirements": ["home-assistant-frontend==20250627.0"]
}
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import mimetypes
from pathlib import Path
from types import MappingProxyType
from google.genai import Client
from google.genai.errors import APIError, ClientError
@@ -36,10 +37,13 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_PROMPT,
DEFAULT_TITLE,
DEFAULT_TTS_NAME,
DOMAIN,
FILE_POLLING_INTERVAL_SECONDS,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_TTS_OPTIONS,
TIMEOUT_MILLIS,
)
@@ -242,6 +246,16 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
hass.config_entries.async_add_subentry(parent_entry, subentry)
if use_existing:
hass.config_entries.async_add_subentry(
parent_entry,
ConfigSubentry(
data=MappingProxyType(RECOMMENDED_TTS_OPTIONS),
subentry_type="tts",
title=DEFAULT_TTS_NAME,
unique_id=None,
),
)
conversation_entity = entity_registry.async_get_entity_id(
"conversation",
DOMAIN,
@@ -270,12 +284,19 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_TITLE,
options={},
version=2,
)
@@ -47,13 +47,18 @@ from .const import (
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_CONVERSATION_NAME,
DEFAULT_TITLE,
DEFAULT_TTS_NAME,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
RECOMMENDED_TTS_MODEL,
RECOMMENDED_TTS_OPTIONS,
RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
TIMEOUT_MILLIS,
)
@@ -66,12 +71,6 @@ STEP_API_DATA_SCHEMA = vol.Schema(
}
)
RECOMMENDED_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
async def validate_input(data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
@@ -118,15 +117,21 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
data=user_input,
)
return self.async_create_entry(
title="Google Generative AI",
title=DEFAULT_TITLE,
data=user_input,
subentries=[
{
"subentry_type": "conversation",
"data": RECOMMENDED_OPTIONS,
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
}
},
{
"subentry_type": "tts",
"data": RECOMMENDED_TTS_OPTIONS,
"title": DEFAULT_TTS_NAME,
"unique_id": None,
},
],
)
return self.async_show_form(
@@ -172,10 +177,13 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"conversation": ConversationSubentryFlowHandler}
return {
"conversation": LLMSubentryFlowHandler,
"tts": LLMSubentryFlowHandler,
}
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
class LLMSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
last_rendered_recommended = False
@@ -202,7 +210,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if user_input is None:
if self._is_new:
options = RECOMMENDED_OPTIONS.copy()
options: dict[str, Any]
if self._subentry_type == "tts":
options = RECOMMENDED_TTS_OPTIONS.copy()
else:
options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
else:
# If this is a reconfiguration, we need to copy the existing options
# so that we can show the current values in the form.
@@ -216,7 +228,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None)
# Don't allow to save options that enable the Google Seearch tool with an Assist API
# Don't allow to save options that enable the Google Search tool with an Assist API
if not (
user_input.get(CONF_LLM_HASS_API)
and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
@@ -240,7 +252,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
options = user_input
schema = await google_generative_ai_config_option_schema(
self.hass, self._is_new, options, self._genai_client
self.hass, self._is_new, self._subentry_type, options, self._genai_client
)
return self.async_show_form(
step_id="set_options", data_schema=vol.Schema(schema), errors=errors
@@ -253,6 +265,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
async def google_generative_ai_config_option_schema(
hass: HomeAssistant,
is_new: bool,
subentry_type: str,
options: Mapping[str, Any],
genai_client: genai.Client,
) -> dict:
@@ -270,26 +283,39 @@ async def google_generative_ai_config_option_schema(
suggested_llm_apis = [suggested_llm_apis]
if is_new:
if CONF_NAME in options:
default_name = options[CONF_NAME]
elif subentry_type == "tts":
default_name = DEFAULT_TTS_NAME
else:
default_name = DEFAULT_CONVERSATION_NAME
schema: dict[vol.Required | vol.Optional, Any] = {
vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str,
vol.Required(CONF_NAME, default=default_name): str,
}
else:
schema = {}
if subentry_type == "conversation":
schema.update(
{
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": options.get(
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": suggested_llm_apis},
): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True)
),
}
)
schema.update(
{
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": options.get(
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": suggested_llm_apis},
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
@@ -310,7 +336,7 @@ async def google_generative_ai_config_option_schema(
if (
api_model.display_name
and api_model.name
and "tts" not in api_model.name
and ("tts" in api_model.name) == (subentry_type == "tts")
and "vision" not in api_model.name
and api_model.supported_actions
and "generateContent" in api_model.supported_actions
@@ -341,12 +367,17 @@ async def google_generative_ai_config_option_schema(
)
)
if subentry_type == "tts":
default_model = RECOMMENDED_TTS_MODEL
else:
default_model = RECOMMENDED_CHAT_MODEL
schema.update(
{
vol.Optional(
CONF_CHAT_MODEL,
description={"suggested_value": options.get(CONF_CHAT_MODEL)},
default=RECOMMENDED_CHAT_MODEL,
default=default_model,
): SelectSelector(
SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models)
),
@@ -396,13 +427,18 @@ async def google_generative_ai_config_option_schema(
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_USE_GOOGLE_SEARCH_TOOL,
description={
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
},
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
): bool,
}
)
if subentry_type == "conversation":
schema.update(
{
vol.Optional(
CONF_USE_GOOGLE_SEARCH_TOOL,
description={
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
},
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
): bool,
}
)
return schema
@@ -2,17 +2,21 @@
import logging
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.helpers import llm
DOMAIN = "google_generative_ai_conversation"
DEFAULT_TITLE = "Google Generative AI"
LOGGER = logging.getLogger(__package__)
CONF_PROMPT = "prompt"
DEFAULT_CONVERSATION_NAME = "Google AI Conversation"
DEFAULT_TTS_NAME = "Google AI TTS"
ATTR_MODEL = "model"
CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts"
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p"
@@ -31,3 +35,12 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
TIMEOUT_MILLIS = 10000
FILE_POLLING_INTERVAL_SECONDS = 0.05
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_RECOMMENDED: True,
}
RECOMMENDED_TTS_OPTIONS = {
CONF_RECOMMENDED: True,
}
@@ -21,6 +21,7 @@ async def async_get_config_entry_diagnostics(
"title": entry.title,
"data": entry.data,
"options": entry.options,
"subentries": dict(entry.subentries),
},
TO_REDACT,
)
@@ -301,7 +301,12 @@ async def _transform_stream(
class GoogleGenerativeAILLMBaseEntity(Entity):
"""Google Generative AI base entity."""
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
def __init__(
self,
entry: ConfigEntry,
subentry: ConfigSubentry,
default_model: str = RECOMMENDED_CHAT_MODEL,
) -> None:
"""Initialize the agent."""
self.entry = entry
self.subentry = subentry
@@ -312,7 +317,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Google",
model="Generative AI",
model=subentry.data.get(CONF_CHAT_MODEL, default_model).split("/")[-1],
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -0,0 +1,73 @@
"""Helper classes for Google Generative AI integration."""
from __future__ import annotations
from contextlib import suppress
import io
import wave
from homeassistant.exceptions import HomeAssistantError
from .const import LOGGER
def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes:
"""Generate a WAV file header for the given audio data and parameters.
Args:
audio_data: The raw audio data as a bytes object.
mime_type: Mime type of the audio data.
Returns:
A bytes object representing the WAV file header.
"""
parameters = _parse_audio_mime_type(mime_type)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(parameters["bits_per_sample"] // 8)
wf.setframerate(parameters["rate"])
wf.writeframes(audio_data)
return wav_buffer.getvalue()
# Below code is from https://aistudio.google.com/app/generate-speech
# when you select "Get SDK code to generate speech".
def _parse_audio_mime_type(mime_type: str) -> dict[str, int]:
"""Parse bits per sample and rate from an audio MIME type string.
Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx".
Args:
mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000").
Returns:
A dictionary with "bits_per_sample" and "rate" keys. Values will be
integers if found, otherwise None.
"""
if not mime_type.startswith("audio/L"):
LOGGER.warning("Received unexpected MIME type %s", mime_type)
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
bits_per_sample = 16
rate = 24000
# Extract rate from parameters
parts = mime_type.split(";")
for param in parts: # Skip the main type part
param = param.strip()
if param.lower().startswith("rate="):
# Handle cases like "rate=" with no value or non-integer value and keep rate as default
with suppress(ValueError, IndexError):
rate_str = param.split("=", 1)[1]
rate = int(rate_str)
elif param.startswith("audio/L"):
# Keep bits_per_sample as default if conversion fails
with suppress(ValueError, IndexError):
bits_per_sample = int(param.split("L", 1)[1])
return {"bits_per_sample": bits_per_sample, "rate": rate}
@@ -29,7 +29,6 @@
"reconfigure": "Reconfigure conversation agent"
},
"entry_type": "Conversation agent",
"step": {
"set_options": {
"data": {
@@ -61,6 +60,34 @@
"error": {
"invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting."
}
},
"tts": {
"initiate_flow": {
"user": "Add Text-to-Speech service",
"reconfigure": "Reconfigure Text-to-Speech service"
},
"entry_type": "Text-to-Speech",
"step": {
"set_options": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]",
"chat_model": "[%key:common::generic::model%]",
"temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]",
"top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]",
"top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]",
"max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]",
"harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]",
"hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]",
"sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]",
"dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]"
}
}
},
"abort": {
"entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
},
"services": {
@@ -2,13 +2,12 @@
from __future__ import annotations
from contextlib import suppress
import io
import logging
from collections.abc import Mapping
from typing import Any
import wave
from google.genai import types
from google.genai.errors import APIError, ClientError
from propcache.api import cached_property
from homeassistant.components.tts import (
ATTR_VOICE,
@@ -16,15 +15,14 @@ from homeassistant.components.tts import (
TtsAudioType,
Voice,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL
_LOGGER = logging.getLogger(__name__)
from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL
from .entity import GoogleGenerativeAILLMBaseEntity
from .helpers import convert_to_wav
async def async_setup_entry(
@@ -32,15 +30,23 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TTS entity."""
tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry)
async_add_entities([tts_entity])
"""Set up TTS entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "tts":
continue
async_add_entities(
[GoogleGenerativeAITextToSpeechEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
class GoogleGenerativeAITextToSpeechEntity(
TextToSpeechEntity, GoogleGenerativeAILLMBaseEntity
):
"""Google Generative AI text-to-speech entity."""
_attr_supported_options = [ATTR_VOICE, ATTR_MODEL]
_attr_supported_options = [ATTR_VOICE]
# See https://ai.google.dev/gemini-api/docs/speech-generation#languages
_attr_supported_languages = [
"ar-EG",
@@ -68,6 +74,8 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
"uk-UA",
"vi-VN",
]
# Unused, but required by base class.
# The Gemini TTS models detect the input language automatically.
_attr_default_language = "en-US"
# See https://ai.google.dev/gemini-api/docs/speech-generation#voices
_supported_voices = [
@@ -106,110 +114,44 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
)
]
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize Google Generative AI Conversation speech entity."""
self.entry = entry
self._attr_name = "Google Generative AI TTS"
self._attr_unique_id = f"{entry.entry_id}_tts"
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Google",
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
self._genai_client = entry.runtime_data
self._default_voice_id = self._supported_voices[0].voice_id
def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the TTS entity."""
super().__init__(config_entry, subentry, RECOMMENDED_TTS_MODEL)
@callback
def async_get_supported_voices(self, language: str) -> list[Voice] | None:
def async_get_supported_voices(self, language: str) -> list[Voice]:
"""Return a list of supported voices for a language."""
return self._supported_voices
@cached_property
def default_options(self) -> Mapping[str, Any]:
"""Return a mapping with the default options."""
return {
ATTR_VOICE: self._supported_voices[0].voice_id,
}
async def async_get_tts_audio(
self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from the engine."""
try:
response = self._genai_client.models.generate_content(
model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL),
contents=message,
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name=options.get(
ATTR_VOICE, self._default_voice_id
)
)
)
),
),
config = self.create_generate_content_config()
config.response_modalities = ["AUDIO"]
config.speech_config = types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name=options[ATTR_VOICE]
)
)
)
try:
response = await self._genai_client.aio.models.generate_content(
model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_TTS_MODEL),
contents=message,
config=config,
)
data = response.candidates[0].content.parts[0].inline_data.data
mime_type = response.candidates[0].content.parts[0].inline_data.mime_type
except Exception as exc:
_LOGGER.warning(
"Error during processing of TTS request %s", exc, exc_info=True
)
except (APIError, ClientError, ValueError) as exc:
LOGGER.error("Error during TTS: %s", exc, exc_info=True)
raise HomeAssistantError(exc) from exc
return "wav", self._convert_to_wav(data, mime_type)
def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes:
"""Generate a WAV file header for the given audio data and parameters.
Args:
audio_data: The raw audio data as a bytes object.
mime_type: Mime type of the audio data.
Returns:
A bytes object representing the WAV file header.
"""
parameters = self._parse_audio_mime_type(mime_type)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(parameters["bits_per_sample"] // 8)
wf.setframerate(parameters["rate"])
wf.writeframes(audio_data)
return wav_buffer.getvalue()
def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]:
"""Parse bits per sample and rate from an audio MIME type string.
Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx".
Args:
mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000").
Returns:
A dictionary with "bits_per_sample" and "rate" keys. Values will be
integers if found, otherwise None.
"""
if not mime_type.startswith("audio/L"):
_LOGGER.warning("Received unexpected MIME type %s", mime_type)
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
bits_per_sample = 16
rate = 24000
# Extract rate from parameters
parts = mime_type.split(";")
for param in parts: # Skip the main type part
param = param.strip()
if param.lower().startswith("rate="):
# Handle cases like "rate=" with no value or non-integer value and keep rate as default
with suppress(ValueError, IndexError):
rate_str = param.split("=", 1)[1]
rate = int(rate_str)
elif param.startswith("audio/L"):
# Keep bits_per_sample as default if conversion fails
with suppress(ValueError, IndexError):
bits_per_sample = int(param.split("L", 1)[1])
return {"bits_per_sample": bits_per_sample, "rate": rate}
return "wav", convert_to_wav(data, mime_type)
+1 -1
View File
@@ -9,7 +9,7 @@ ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/"
SITE_DATA_URL = "https://habitica.com/user/settings/siteData"
FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password"
SIGN_UP_URL = "https://habitica.com/register"
HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png"
HABITICANS_URL = "https://cdn.habitica.com/assets/home-main@3x-Dwnue45Z.png"
DOMAIN = "habitica"
@@ -7,7 +7,10 @@ import asyncio
import logging
from typing import Any
from ha_silabs_firmware_client import FirmwareUpdateClient
from aiohttp import ClientError
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
from universal_silabs_flasher.common import Version
from universal_silabs_flasher.firmware import NabuCasaMetadata
from homeassistant.components.hassio import (
AddonError,
@@ -149,15 +152,78 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
assert self._device is not None
if not self.firmware_install_task:
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
manifest = await client.async_update_data()
fw_meta = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
# We 100% need to install new firmware only if the wrong firmware is
# currently installed
firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type
!= expected_installed_firmware_type
)
fw_data = await client.async_fetch_firmware(fw_meta)
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
try:
manifest = await client.async_update_data()
fw_manifest = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
)
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
_LOGGER.warning(
"Failed to fetch firmware update manifest", exc_info=True
)
# Not having internet access should not prevent setup
if not firmware_install_required:
_LOGGER.debug(
"Skipping firmware upgrade due to index download failure"
)
return self.async_show_progress_done(next_step_id=next_step_id)
raise AbortFlow(
"fw_download_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
) from err
if not firmware_install_required:
assert self._probed_firmware_info is not None
# Make sure we do not downgrade the firmware
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
fw_version = fw_metadata.get_public_version()
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
if probed_fw_version >= fw_version:
_LOGGER.debug(
"Not downgrading firmware, installed %s is newer than available %s",
probed_fw_version,
fw_version,
)
return self.async_show_progress_done(next_step_id=next_step_id)
try:
fw_data = await client.async_fetch_firmware(fw_manifest)
except (TimeoutError, ClientError, ValueError) as err:
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
# If we cannot download new firmware, we shouldn't block setup
if not firmware_install_required:
_LOGGER.debug(
"Skipping firmware upgrade due to image download failure"
)
return self.async_show_progress_done(next_step_id=next_step_id)
# Otherwise, fail
raise AbortFlow(
"fw_download_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
) from err
self.firmware_install_task = self.hass.async_create_task(
async_flash_silabs_firmware(
hass=self.hass,
@@ -215,6 +281,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
},
)
async def async_step_pre_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pre-confirm Zigbee setup."""
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_confirm_zigbee()
async def async_step_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -409,7 +483,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
finally:
self.addon_start_task = None
return self.async_show_progress_done(next_step_id="confirm_otbr")
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
async def async_step_pre_confirm_otbr(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pre-confirm OTBR setup."""
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_confirm_otbr()
async def async_step_confirm_otbr(
self, user_input: dict[str, Any] | None = None
@@ -36,7 +36,8 @@
"otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.",
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device."
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again."
},
"progress": {
"install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."
@@ -93,7 +93,7 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="confirm_zigbee",
next_step_id="pre_confirm_zigbee",
)
async def async_step_install_thread_firmware(
@@ -92,7 +92,8 @@
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]"
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
@@ -145,7 +146,8 @@
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]"
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
@@ -117,7 +117,8 @@
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device."
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/honeywell",
"iot_class": "cloud_polling",
"loggers": ["somecomfort"],
"requirements": ["AIOSomecomfort==0.0.32"]
"requirements": ["AIOSomecomfort==0.0.33"]
}
+1 -1
View File
@@ -223,7 +223,7 @@ async def async_setup_auth(
# We first start with a string check to avoid parsing query params
# for every request.
elif (
request.method == "GET"
request.method in ["GET", "HEAD"]
and SIGN_QUERY_PARAM in request.query_string
and async_validate_signed_request(request)
):
@@ -90,7 +90,9 @@ class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity):
@property
def available(self) -> bool:
"""Return the available attribute of the entity."""
return self.entity_description.available_fn(self.mower_attributes)
return super().available and self.entity_description.available_fn(
self.mower_attributes
)
@handle_sending_exception()
async def async_press(self) -> None:
+34 -3
View File
@@ -288,8 +288,10 @@ class ImageView(HomeAssistantView):
"""Initialize an image view."""
self.component = component
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
@@ -306,6 +308,31 @@ class ImageView(HomeAssistantView):
# Invalid sigAuth or image entity access token
raise web.HTTPForbidden
return image_entity
async def head(self, request: web.Request, entity_id: str) -> web.Response:
"""Start a HEAD request.
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = await self._authenticate_request(request, entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
try:
image = await _async_get_image(image_entity, IMAGE_TIMEOUT)
except (HomeAssistantError, ValueError) as ex:
raise web.HTTPInternalServerError from ex
return web.Response(
content_type=image.content_type,
headers={"Content-Length": str(len(image.content))},
)
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = await self._authenticate_request(request, entity_id)
return await self.handle(request, image_entity)
async def handle(
@@ -317,7 +344,11 @@ class ImageView(HomeAssistantView):
except (HomeAssistantError, ValueError) as ex:
raise web.HTTPInternalServerError from ex
return web.Response(body=image.content, content_type=image.content_type)
return web.Response(
body=image.content,
content_type=image.content_type,
headers={"Content-Length": str(len(image.content))},
)
async def async_get_still_stream(
@@ -14,5 +14,5 @@
"iot_class": "local_polling",
"loggers": ["pynecil"],
"quality_scale": "platinum",
"requirements": ["pynecil==4.1.0"]
"requirements": ["pynecil==4.1.1"]
}
@@ -66,8 +66,7 @@ def _connect_to_address(
) -> dict[str, Any]:
"""Connect to the Jellyfin server."""
result: dict[str, Any] = connection_manager.connect_to_address(url)
if result["State"] != CONNECTION_STATE["ServerSignIn"]:
if CONNECTION_STATE(result["State"]) != CONNECTION_STATE.ServerSignIn:
raise CannotConnect
return result
@@ -54,6 +54,9 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, An
self.api_client.jellyfin.sessions
)
if sessions is None:
return {}
sessions_by_id: dict[str, dict[str, Any]] = {
session["Id"]: session
for session in sessions
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["jellyfin_apiclient_python"],
"requirements": ["jellyfin-apiclient-python==1.10.0"],
"single_config_entry": true
"requirements": ["jellyfin-apiclient-python==1.11.0"]
}
+19 -1
View File
@@ -2,8 +2,10 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from pylamarzocco.const import FirmwareType
from pylamarzocco.const import FirmwareType, MachineState, WidgetType
from pylamarzocco.models import MachineStatus
from homeassistant.const import CONF_ADDRESS, CONF_MAC
from homeassistant.helpers.device_registry import (
@@ -32,6 +34,7 @@ class LaMarzoccoBaseEntity(
"""Common elements for all entities."""
_attr_has_entity_name = True
_unavailable_when_machine_off = True
def __init__(
self,
@@ -63,6 +66,21 @@ class LaMarzoccoBaseEntity(
if connections:
self._attr_device_info.update(DeviceInfo(connections=connections))
@property
def available(self) -> bool:
"""Return True if entity is available."""
machine_state = (
cast(
MachineStatus,
self.coordinator.device.dashboard.config[WidgetType.CM_MACHINE_STATUS],
).status
if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config
else MachineState.OFF
)
return super().available and not (
self._unavailable_when_machine_off and machine_state is MachineState.OFF
)
class LaMarzoccoEntity(LaMarzoccoBaseEntity):
"""Common elements for all entities."""
@@ -58,10 +58,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
).target_temperature
),
available_fn=(
lambda coordinator: WidgetType.CM_COFFEE_BOILER
in coordinator.device.dashboard.config
),
),
LaMarzoccoNumberEntityDescription(
key="smart_standby_time",
@@ -57,10 +57,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
).ready_start_time
),
entity_category=EntityCategory.DIAGNOSTIC,
available_fn=(
lambda coordinator: WidgetType.CM_COFFEE_BOILER
in coordinator.device.dashboard.config
),
),
LaMarzoccoSensorEntityDescription(
key="steam_boiler_ready_time",
@@ -188,6 +184,8 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
class LaMarzoccoStatisticSensorEntity(LaMarzoccoSensorEntity):
"""Sensor for La Marzocco statistics."""
_unavailable_when_machine_off = False
@property
def native_value(self) -> StateType | datetime | None:
"""Return the value of the sensor."""
+1 -1
View File
@@ -42,5 +42,5 @@ class LaMetricUpdate(LaMetricEntity, UpdateEntity):
def latest_version(self) -> str | None:
"""Return the latest version of the entity."""
if not self.coordinator.data.update:
return None
return self.coordinator.data.os_version
return self.coordinator.data.update.version
+38 -26
View File
@@ -62,14 +62,25 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
_last_accepted_commands: list[int] | None = None
_supported_run_modes: (
dict[int, clusters.RvcCleanMode.Structs.ModeOptionStruct] | None
dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None
) = None
entity_description: StateVacuumEntityDescription
_platform_translation_key = "vacuum"
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner."""
await self.send_device_command(clusters.OperationalState.Commands.Stop())
# We simply set the RvcRunMode to the first runmode
# that has the idle tag to stop the vacuum cleaner.
# this is compatible with both Matter 1.2 and 1.3+ devices.
supported_run_modes = self._supported_run_modes or {}
for mode in supported_run_modes.values():
for tag in mode.modeTags:
if tag.value == ModeTag.IDLE:
# stop the vacuum by changing the run mode to idle
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
return
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock."""
@@ -83,15 +94,30 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
"""Start or resume the cleaning task."""
if TYPE_CHECKING:
assert self._last_accepted_commands is not None
accepted_operational_commands = self._last_accepted_commands
if (
clusters.RvcOperationalState.Commands.Resume.command_id
in self._last_accepted_commands
in accepted_operational_commands
and self.state == VacuumActivity.PAUSED
):
# vacuum is paused and supports resume command
await self.send_device_command(
clusters.RvcOperationalState.Commands.Resume()
)
else:
await self.send_device_command(clusters.OperationalState.Commands.Start())
return
# We simply set the RvcRunMode to the first runmode
# that has the cleaning tag to start the vacuum cleaner.
# this is compatible with both Matter 1.2 and 1.3+ devices.
supported_run_modes = self._supported_run_modes or {}
for mode in supported_run_modes.values():
for tag in mode.modeTags:
if tag.value == ModeTag.CLEANING:
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
return
async def async_pause(self) -> None:
"""Pause the cleaning task."""
@@ -130,6 +156,8 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
state = VacuumActivity.CLEANING
elif ModeTag.IDLE in tags:
state = VacuumActivity.IDLE
elif ModeTag.MAPPING in tags:
state = VacuumActivity.CLEANING
self._attr_activity = state
@callback
@@ -143,7 +171,10 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
return
self._last_accepted_commands = accepted_operational_commands
supported_features: VacuumEntityFeature = VacuumEntityFeature(0)
supported_features |= VacuumEntityFeature.START
supported_features |= VacuumEntityFeature.STATE
supported_features |= VacuumEntityFeature.STOP
# optional battery attribute = battery feature
if self.get_matter_attribute_value(
clusters.PowerSource.Attributes.BatPercentRemaining
@@ -153,7 +184,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType):
supported_features |= VacuumEntityFeature.LOCATE
# create a map of supported run modes
run_modes: list[clusters.RvcCleanMode.Structs.ModeOptionStruct] = (
run_modes: list[clusters.RvcRunMode.Structs.ModeOptionStruct] = (
self.get_matter_attribute_value(
clusters.RvcRunMode.Attributes.SupportedModes
)
@@ -165,22 +196,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
in accepted_operational_commands
):
supported_features |= VacuumEntityFeature.PAUSE
if (
clusters.OperationalState.Commands.Stop.command_id
in accepted_operational_commands
):
supported_features |= VacuumEntityFeature.STOP
if (
clusters.OperationalState.Commands.Start.command_id
in accepted_operational_commands
):
# note that start has been replaced by resume in rev2 of the spec
supported_features |= VacuumEntityFeature.START
if (
clusters.RvcOperationalState.Commands.Resume.command_id
in accepted_operational_commands
):
supported_features |= VacuumEntityFeature.START
if (
clusters.RvcOperationalState.Commands.GoHome.command_id
in accepted_operational_commands
@@ -202,10 +217,7 @@ DISCOVERY_SCHEMAS = [
clusters.RvcRunMode.Attributes.CurrentMode,
clusters.RvcOperationalState.Attributes.OperationalState,
),
optional_attributes=(
clusters.RvcCleanMode.Attributes.CurrentMode,
clusters.PowerSource.Attributes.BatPercentRemaining,
),
optional_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,),
device_type=(device_types.RoboticVacuumCleaner,),
allow_none_value=True,
),
+5 -1
View File
@@ -25,4 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[MEATER_DATA] = (
hass.data[MEATER_DATA] - entry.runtime_data.found_probes
)
return unload_ok
@@ -44,6 +44,7 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]):
)
session = async_get_clientsession(hass)
self.client = MeaterApi(session)
self.found_probes: set[str] = set()
async def _async_setup(self) -> None:
"""Set up the Meater Coordinator."""
@@ -73,5 +74,6 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]):
raise UpdateFailed(
"Too many requests have been made to the API, rate limiting is in place"
) from err
return {device.id: device for device in devices}
res = {device.id: device for device in devices}
self.found_probes.update(set(res.keys()))
return res
@@ -210,10 +210,8 @@ class LocalMediaView(http.HomeAssistantView):
self.hass = hass
self.source = source
async def get(
self, request: web.Request, source_dir_id: str, location: str
) -> web.FileResponse:
"""Start a GET request."""
async def _validate_media_path(self, source_dir_id: str, location: str) -> Path:
"""Validate media path and return it if valid."""
try:
raise_if_invalid_path(location)
except ValueError as err:
@@ -233,6 +231,25 @@ class LocalMediaView(http.HomeAssistantView):
if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES:
raise web.HTTPNotFound
return media_path
async def head(
self, request: web.Request, source_dir_id: str, location: str
) -> None:
"""Handle a HEAD request.
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
Check whether the location exists or not.
"""
await self._validate_media_path(source_dir_id, location)
async def get(
self, request: web.Request, source_dir_id: str, location: str
) -> web.FileResponse:
"""Handle a GET request."""
media_path = await self._validate_media_path(source_dir_id, location)
return web.FileResponse(media_path)
@@ -41,12 +41,6 @@ class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity):
translation_key="favorite_now_playing",
)
@property
def available(self) -> bool:
"""Return availability of entity."""
# mark the button as unavailable if the player has no current media item
return super().available and self.player.current_media is not None
@catch_musicassistant_error
async def async_press(self) -> None:
"""Handle the button press command."""
+4 -2
View File
@@ -39,10 +39,12 @@ def _base_schema(
base_schema = {
vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str,
vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int,
vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str,
vol.Optional(
CONF_USERNAME, default=nut_config.get(CONF_USERNAME, vol.UNDEFINED)
): str,
vol.Optional(
CONF_PASSWORD,
default=PASSWORD_NOT_CHANGED if use_password_not_changed else "",
default=PASSWORD_NOT_CHANGED if use_password_not_changed else vol.UNDEFINED,
): str,
}
@@ -27,6 +27,7 @@ from .const import (
CONF_NUM_CTX,
CONF_PROMPT,
CONF_THINK,
DEFAULT_NAME,
DEFAULT_TIMEOUT,
DOMAIN,
)
@@ -132,12 +133,19 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_NAME,
options={},
version=2,
)
+2
View File
@@ -2,6 +2,8 @@
DOMAIN = "ollama"
DEFAULT_NAME = "Ollama"
CONF_MODEL = "model"
CONF_PROMPT = "prompt"
CONF_THINK = "think"
@@ -49,6 +49,7 @@ from .const import (
CONF_REASONING_EFFORT,
CONF_TEMPERATURE,
CONF_TOP_P,
DEFAULT_NAME,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
@@ -345,12 +346,19 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_NAME,
options={},
version=2,
)
@@ -6,12 +6,12 @@ DOMAIN = "openai_conversation"
LOGGER: logging.Logger = logging.getLogger(__package__)
DEFAULT_CONVERSATION_NAME = "OpenAI Conversation"
DEFAULT_NAME = "OpenAI Conversation"
CONF_CHAT_MODEL = "chat_model"
CONF_FILENAMES = "filenames"
CONF_MAX_TOKENS = "max_tokens"
CONF_PROMPT = "prompt"
CONF_PROMPT = "prompt"
CONF_REASONING_EFFORT = "reasoning_effort"
CONF_RECOMMENDED = "recommended"
CONF_TEMPERATURE = "temperature"
@@ -247,7 +247,7 @@ class OpenAIConversationEntity(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="OpenAI",
model=entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
entry_type=dr.DeviceEntryType.SERVICE,
)
if self.subentry.data.get(CONF_LLM_HASS_API):
@@ -0,0 +1,55 @@
"""Diagnostics support for PlayStation Network."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from psnawp_api.models.trophies import PlatformType
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator
TO_REDACT = {
"account_id",
"firstName",
"lastName",
"middleName",
"onlineId",
"url",
"username",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: PlaystationNetworkConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: PlaystationNetworkCoordinator = entry.runtime_data
return {
"data": async_redact_data(
_serialize_platform_types(asdict(coordinator.data)), TO_REDACT
),
}
def _serialize_platform_types(data: Any) -> Any:
"""Recursively convert PlatformType enums to strings in dicts and sets."""
if isinstance(data, dict):
return {
(
platform.value if isinstance(platform, PlatformType) else platform
): _serialize_platform_types(record)
for platform, record in data.items()
}
if isinstance(data, set):
return [
record.value if isinstance(record, PlatformType) else record
for record in data
]
if isinstance(data, PlatformType):
return data.value
return data
@@ -44,7 +44,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: Discovery flow is not applicable for this integration
+6 -2
View File
@@ -192,8 +192,12 @@ def async_setup_rpc_attribute_entities(
if description.removal_condition and description.removal_condition(
coordinator.device.config, coordinator.device.status, key
):
domain = sensor_class.__module__.split(".")[-1]
unique_id = f"{coordinator.mac}-{key}-{sensor_id}"
entity_class = get_entity_class(sensor_class, description)
domain = entity_class.__module__.split(".")[-1]
unique_id = entity_class(
coordinator, key, sensor_id, description
).unique_id
LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id)
async_remove_shelly_entity(hass, domain, unique_id)
elif description.use_polling_coordinator:
if not sleep_period:
+2 -2
View File
@@ -5,7 +5,7 @@ from pysmarlaapi import Connection, Federwiege
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryError
from .const import HOST, PLATFORMS
@@ -18,7 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -
# Check if token still has access
if not await connection.refresh_token():
raise ConfigEntryAuthFailed("Invalid authentication")
raise ConfigEntryError("Invalid authentication")
federwiege = Federwiege(hass.loop, connection)
federwiege.register()
+2 -2
View File
@@ -102,11 +102,11 @@
"services": {
"unlock_specific_door": {
"name": "Unlock specific door",
"description": "Unlocks specific door(s).",
"description": "Unlocks the driver door, all doors, or the tailgate.",
"fields": {
"door": {
"name": "Door",
"description": "Which door(s) to open."
"description": "The specific door(s) to unlock."
}
}
}
@@ -29,6 +29,7 @@ from homeassistant.core import (
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.helpers import config_validation as cv
@@ -390,9 +391,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
elif msgtype == SERVICE_DELETE_MESSAGE:
await notify_service.delete_message(context=service.context, **kwargs)
elif msgtype == SERVICE_LEAVE_CHAT:
messages = await notify_service.leave_chat(
context=service.context, **kwargs
)
await notify_service.leave_chat(context=service.context, **kwargs)
elif msgtype == SERVICE_SET_MESSAGE_REACTION:
await notify_service.set_message_reaction(context=service.context, **kwargs)
else:
@@ -400,12 +399,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
msgtype, context=service.context, **kwargs
)
if service.return_response and messages:
if service.return_response and messages is not None:
target: list[int] | None = service.data.get(ATTR_TARGET)
if not target:
target = notify_service.get_target_chat_ids(None)
failed_chat_ids = [chat_id for chat_id in target if chat_id not in messages]
if failed_chat_ids:
raise HomeAssistantError(
f"Failed targets: {failed_chat_ids}",
translation_domain=DOMAIN,
translation_key="failed_chat_ids",
translation_placeholders={
"chat_ids": ", ".join([str(i) for i in failed_chat_ids]),
"bot_name": config_entry.title,
},
)
return {
"chats": [
{"chat_id": cid, "message_id": mid} for cid, mid in messages.items()
]
}
return None
# Register notification services
+33 -29
View File
@@ -287,24 +287,32 @@ class TelegramNotificationService:
inline_message_id = msg_data["inline_message_id"]
return message_id, inline_message_id
def _get_target_chat_ids(self, target: Any) -> list[int]:
def get_target_chat_ids(self, target: int | list[int] | None) -> list[int]:
"""Validate chat_id targets or return default target (first).
:param target: optional list of integers ([12234, -12345])
:return list of chat_id targets (integers)
"""
allowed_chat_ids: list[int] = self._get_allowed_chat_ids()
default_user: int = allowed_chat_ids[0]
if target is not None:
if isinstance(target, int):
target = [target]
chat_ids = [t for t in target if t in allowed_chat_ids]
if chat_ids:
return chat_ids
_LOGGER.warning(
"Disallowed targets: %s, using default: %s", target, default_user
if target is None:
return [allowed_chat_ids[0]]
chat_ids = [target] if isinstance(target, int) else target
valid_chat_ids = [
chat_id for chat_id in chat_ids if chat_id in allowed_chat_ids
]
if not valid_chat_ids:
raise ServiceValidationError(
"Invalid chat IDs",
translation_domain=DOMAIN,
translation_key="invalid_chat_ids",
translation_placeholders={
"chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids),
"bot_name": self.config.title,
},
)
return [default_user]
return valid_chat_ids
def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]:
"""Get parameters in message data kwargs."""
@@ -414,9 +422,9 @@ class TelegramNotificationService:
"""Send one message."""
try:
out = await func_send(*args_msg, **kwargs_msg)
if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID):
if isinstance(out, Message):
chat_id = out.chat_id
message_id = out[ATTR_MESSAGEID]
message_id = out.message_id
self._last_message_id[chat_id] = message_id
_LOGGER.debug(
"Last message ID: %s (from chat_id %s)",
@@ -424,7 +432,7 @@ class TelegramNotificationService:
chat_id,
)
event_data = {
event_data: dict[str, Any] = {
ATTR_CHAT_ID: chat_id,
ATTR_MESSAGEID: message_id,
}
@@ -437,10 +445,6 @@ class TelegramNotificationService:
self.hass.bus.async_fire(
EVENT_TELEGRAM_SENT, event_data, context=context
)
elif not isinstance(out, bool):
_LOGGER.warning(
"Update last message: out_type:%s, out=%s", type(out), out
)
except TelegramError as exc:
_LOGGER.error(
"%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg
@@ -460,7 +464,7 @@ class TelegramNotificationService:
text = f"{title}\n{message}" if title else message
params = self._get_msg_kwargs(kwargs)
msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
for chat_id in self.get_target_chat_ids(target):
_LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params)
msg = await self._send_msg(
self.bot.send_message,
@@ -488,7 +492,7 @@ class TelegramNotificationService:
**kwargs: dict[str, Any],
) -> bool:
"""Delete a previously sent message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
chat_id = self.get_target_chat_ids(chat_id)[0]
message_id, _ = self._get_msg_ids(kwargs, chat_id)
_LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id)
deleted: bool = await self._send_msg(
@@ -513,7 +517,7 @@ class TelegramNotificationService:
**kwargs: dict[str, Any],
) -> Any:
"""Edit a previously sent message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
chat_id = self.get_target_chat_ids(chat_id)[0]
message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id)
params = self._get_msg_kwargs(kwargs)
_LOGGER.debug(
@@ -620,7 +624,7 @@ class TelegramNotificationService:
msg_ids = {}
if file_content:
for chat_id in self._get_target_chat_ids(target):
for chat_id in self.get_target_chat_ids(target):
_LOGGER.debug("Sending file to chat ID %s", chat_id)
if file_type == SERVICE_SEND_PHOTO:
@@ -738,7 +742,7 @@ class TelegramNotificationService:
msg_ids = {}
if stickerid:
for chat_id in self._get_target_chat_ids(target):
for chat_id in self.get_target_chat_ids(target):
msg = await self._send_msg(
self.bot.send_sticker,
"Error sending sticker",
@@ -769,7 +773,7 @@ class TelegramNotificationService:
longitude = float(longitude)
params = self._get_msg_kwargs(kwargs)
msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
for chat_id in self.get_target_chat_ids(target):
_LOGGER.debug(
"Send location %s/%s to chat ID %s", latitude, longitude, chat_id
)
@@ -803,7 +807,7 @@ class TelegramNotificationService:
params = self._get_msg_kwargs(kwargs)
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
for chat_id in self.get_target_chat_ids(target):
_LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id)
msg = await self._send_msg(
self.bot.send_poll,
@@ -826,12 +830,12 @@ class TelegramNotificationService:
async def leave_chat(
self,
chat_id: Any = None,
chat_id: int | None = None,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> Any:
"""Remove bot from chat."""
chat_id = self._get_target_chat_ids(chat_id)[0]
chat_id = self.get_target_chat_ids(chat_id)[0]
_LOGGER.debug("Leave from chat ID %s", chat_id)
return await self._send_msg(
self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context
@@ -839,14 +843,14 @@ class TelegramNotificationService:
async def set_message_reaction(
self,
chat_id: int,
reaction: str,
chat_id: int | None = None,
is_big: bool = False,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> None:
"""Set the bot's reaction for a given message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
chat_id = self.get_target_chat_ids(chat_id)[0]
message_id, _ = self._get_msg_ids(kwargs, chat_id)
params = self._get_msg_kwargs(kwargs)
@@ -22,7 +22,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, CONF_URL
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.data_entry_flow import AbortFlow, section
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.network import NoURLAvailableError, get_url
@@ -58,6 +58,7 @@ from .const import (
PLATFORM_BROADCAST,
PLATFORM_POLLING,
PLATFORM_WEBHOOKS,
SECTION_ADVANCED_SETTINGS,
SUBENTRY_TYPE_ALLOWED_CHAT_IDS,
)
@@ -81,8 +82,15 @@ STEP_USER_DATA_SCHEMA: vol.Schema = vol.Schema(
autocomplete="current-password",
)
),
vol.Optional(CONF_PROXY_URL): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.URL)
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Optional(CONF_PROXY_URL): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.URL)
),
},
),
{"collapsed": True},
),
}
)
@@ -98,8 +106,15 @@ STEP_RECONFIGURE_USER_DATA_SCHEMA: vol.Schema = vol.Schema(
translation_key="platforms",
)
),
vol.Optional(CONF_PROXY_URL): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.URL)
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Optional(CONF_PROXY_URL): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.URL)
),
},
),
{"collapsed": True},
),
}
)
@@ -197,6 +212,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
import_data[CONF_TRUSTED_NETWORKS] = ",".join(
import_data[CONF_TRUSTED_NETWORKS]
)
import_data[SECTION_ADVANCED_SETTINGS] = {
CONF_PROXY_URL: import_data.get(CONF_PROXY_URL)
}
try:
config_flow_result: ConfigFlowResult = await self.async_step_user(
import_data
@@ -293,10 +311,15 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle a flow to create a new config entry for a Telegram bot."""
description_placeholders: dict[str, str] = {
"botfather_username": "@BotFather",
"botfather_url": "https://t.me/botfather",
}
if not user_input:
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
description_placeholders=description_placeholders,
)
# prevent duplicates
@@ -305,7 +328,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
# validate connection to Telegram API
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
bot_name = await self._validate_bot(
user_input, errors, description_placeholders
)
@@ -328,7 +350,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
data={
CONF_PLATFORM: user_input[CONF_PLATFORM],
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_PROXY_URL: user_input.get(CONF_PROXY_URL),
CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL),
},
options={
# this value may come from yaml import
@@ -390,12 +412,20 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for webhook Telegram bot."""
if not user_input:
default_trusted_networks = ",".join(
[str(network) for network in DEFAULT_TRUSTED_NETWORKS]
)
if self.source == SOURCE_RECONFIGURE:
suggested_values = dict(self._get_reconfigure_entry().data)
if CONF_TRUSTED_NETWORKS not in self._get_reconfigure_entry().data:
suggested_values[CONF_TRUSTED_NETWORKS] = default_trusted_networks
return self.async_show_form(
step_id="webhooks",
data_schema=self.add_suggested_values_to_schema(
STEP_WEBHOOKS_DATA_SCHEMA,
self._get_reconfigure_entry().data,
suggested_values,
),
)
@@ -404,9 +434,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=self.add_suggested_values_to_schema(
STEP_WEBHOOKS_DATA_SCHEMA,
{
CONF_TRUSTED_NETWORKS: ",".join(
[str(network) for network in DEFAULT_TRUSTED_NETWORKS]
),
CONF_TRUSTED_NETWORKS: default_trusted_networks,
},
),
)
@@ -440,7 +468,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
data={
CONF_PLATFORM: self._step_user_data[CONF_PLATFORM],
CONF_API_KEY: self._step_user_data[CONF_API_KEY],
CONF_PROXY_URL: self._step_user_data.get(CONF_PROXY_URL),
CONF_PROXY_URL: self._step_user_data[SECTION_ADVANCED_SETTINGS].get(
CONF_PROXY_URL
),
CONF_URL: user_input.get(CONF_URL),
CONF_TRUSTED_NETWORKS: user_input[CONF_TRUSTED_NETWORKS],
},
@@ -455,12 +485,8 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders: dict[str, str],
) -> None:
# validate URL
if CONF_URL in user_input and not user_input[CONF_URL].startswith("https"):
errors["base"] = "invalid_url"
description_placeholders[ERROR_FIELD] = "URL"
description_placeholders[ERROR_MESSAGE] = "URL must start with https"
return
if CONF_URL not in user_input:
url: str | None = user_input.get(CONF_URL)
if url is None:
try:
get_url(self.hass, require_ssl=True, allow_internal=False)
except NoURLAvailableError:
@@ -470,6 +496,11 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
"URL is required since you have not configured an external URL in Home Assistant"
)
return
elif not url.startswith("https"):
errors["base"] = "invalid_url"
description_placeholders[ERROR_FIELD] = "URL"
description_placeholders[ERROR_MESSAGE] = "URL must start with https"
return
# validate trusted networks
csv_trusted_networks: list[str] = []
@@ -505,9 +536,19 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_RECONFIGURE_USER_DATA_SCHEMA,
self._get_reconfigure_entry().data,
{
**self._get_reconfigure_entry().data,
SECTION_ADVANCED_SETTINGS: {
CONF_PROXY_URL: self._get_reconfigure_entry().data.get(
CONF_PROXY_URL
),
},
},
),
)
user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get(
CONF_PROXY_URL
)
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
@@ -523,7 +564,12 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_RECONFIGURE_USER_DATA_SCHEMA,
user_input,
{
**user_input,
SECTION_ADVANCED_SETTINGS: {
CONF_PROXY_URL: user_input.get(CONF_PROXY_URL),
},
},
),
errors=errors,
description_placeholders=description_placeholders,
@@ -7,7 +7,7 @@ DOMAIN = "telegram_bot"
PLATFORM_BROADCAST = "broadcast"
PLATFORM_POLLING = "polling"
PLATFORM_WEBHOOKS = "webhooks"
SECTION_ADVANCED_SETTINGS = "advanced_settings"
SUBENTRY_TYPE_ALLOWED_CHAT_IDS = "allowed_chat_ids"
CONF_BOT_COUNT = "bot_count"
@@ -1,7 +1,7 @@
{
"domain": "telegram_bot",
"name": "Telegram bot",
"codeowners": [],
"codeowners": ["@hanwg"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/telegram_bot",
@@ -2,17 +2,25 @@
"config": {
"step": {
"user": {
"title": "Telegram bot setup",
"description": "Create a new Telegram bot",
"description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.",
"data": {
"platform": "Platform",
"api_key": "[%key:common::config_flow::data::api_key%]",
"proxy_url": "Proxy URL"
"api_key": "[%key:common::config_flow::data::api_token%]"
},
"data_description": {
"platform": "Telegram bot implementation",
"api_key": "The API token of your bot.",
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)"
"api_key": "The API token of your bot."
},
"sections": {
"advanced_settings": {
"name": "Advanced settings",
"data": {
"proxy_url": "Proxy URL"
},
"data_description": {
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)"
}
}
}
},
"webhooks": {
@@ -30,12 +38,21 @@
"title": "Telegram bot setup",
"description": "Reconfigure Telegram bot",
"data": {
"platform": "[%key:component::telegram_bot::config::step::user::data::platform%]",
"proxy_url": "[%key:component::telegram_bot::config::step::user::data::proxy_url%]"
"platform": "[%key:component::telegram_bot::config::step::user::data::platform%]"
},
"data_description": {
"platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]",
"proxy_url": "[%key:component::telegram_bot::config::step::user::data_description::proxy_url%]"
"platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]"
},
"sections": {
"advanced_settings": {
"name": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::name%]",
"data": {
"proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data::proxy_url%]"
},
"data_description": {
"proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data_description::proxy_url%]"
}
}
}
},
"reauth_confirm": {
@@ -895,6 +912,12 @@
"missing_allowed_chat_ids": {
"message": "No allowed chat IDs found. Please add allowed chat IDs for {bot_name}."
},
"invalid_chat_ids": {
"message": "Invalid chat IDs: {chat_ids}. Please configure the chat IDs for {bot_name}."
},
"failed_chat_ids": {
"message": "Failed targets: {chat_ids}. Please verify that the chat IDs for {bot_name} have been configured."
},
"missing_input": {
"message": "{field} is required."
},
@@ -194,14 +194,14 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
if not data or not isinstance(data.get("time_series"), list):
raise UpdateFailed("Received invalid data")
# Add all time periods together
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None)
for period in data.get("time_series", []):
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0)
for period in data["time_series"]:
for key in ENERGY_HISTORY_FIELDS:
if key in period:
if output[key] is None:
output[key] = period[key]
else:
output[key] += period[key]
output[key] += period[key]
return output
@@ -21,7 +21,7 @@
"entity": {
"button": {
"set_datetime": {
"name": "Set Date&Time"
"name": "Set date & time"
}
}
}
+15
View File
@@ -1185,6 +1185,21 @@ class TextToSpeechView(HomeAssistantView):
"""Initialize a tts view."""
self.manager = manager
async def head(self, request: web.Request, token: str) -> web.StreamResponse:
"""Start a HEAD request.
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
Check whether the token (file) exists and return its content type.
"""
stream = self.manager.token_to_stream.get(token)
if stream is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
return web.Response(content_type=stream.content_type)
async def get(self, request: web.Request, token: str) -> web.StreamResponse:
"""Start a get request."""
stream = self.manager.token_to_stream.get(token)
+1 -1
View File
@@ -3105,7 +3105,7 @@ async def websocket_restore_nvm(
driver.once("driver ready", set_driver_ready),
]
await controller.async_restore_nvm_base64(msg["data"])
await controller.async_restore_nvm_base64(msg["data"], {"preserveRoutes": False})
with suppress(TimeoutError):
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
@@ -40,7 +40,6 @@ from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
from .addon import get_addon_manager
from .const import (
@@ -90,6 +89,9 @@ ADDON_USER_INPUT_MAP = {
ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool})
MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61")
NETWORK_TYPE_NEW = "new"
NETWORK_TYPE_EXISTING = "existing"
def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema:
"""Return a schema for the manual step."""
@@ -138,13 +140,15 @@ def get_usb_ports() -> dict[str, str]:
)
port_descriptions[dev_path] = human_name
# Sort the dictionary by description, putting "n/a" last
return dict(
sorted(
port_descriptions.items(),
key=lambda x: x[1].lower().startswith("n/a"),
)
)
# Filter out "n/a" descriptions only if there are other ports available
non_na_ports = {
path: desc
for path, desc in port_descriptions.items()
if not desc.lower().startswith("n/a")
}
# If we have non-"n/a" ports, return only those; otherwise return all ports as-is
return non_na_ports if non_na_ports else port_descriptions
async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]:
@@ -630,6 +634,81 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask for config for Z-Wave JS add-on."""
if user_input is not None:
self.usb_path = user_input[CONF_USB_PATH]
return await self.async_step_network_type()
if self._usb_discovery:
return await self.async_step_network_type()
usb_path = self.usb_path or ""
try:
ports = await async_get_usb_ports(self.hass)
except OSError as err:
_LOGGER.error("Failed to get USB ports: %s", err)
return self.async_abort(reason="usb_ports_failed")
data_schema = vol.Schema(
{
vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports),
}
)
return self.async_show_form(
step_id="configure_addon_user", data_schema=data_schema
)
async def async_step_network_type(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask for network type (new or existing)."""
# For recommended installation, automatically set network type to "new"
if self._recommended_install:
user_input = {"network_type": NETWORK_TYPE_NEW}
if user_input is not None:
if user_input["network_type"] == NETWORK_TYPE_NEW:
# Set all keys to empty strings for new network
self.s0_legacy_key = ""
self.s2_access_control_key = ""
self.s2_authenticated_key = ""
self.s2_unauthenticated_key = ""
self.lr_s2_access_control_key = ""
self.lr_s2_authenticated_key = ""
addon_config_updates = {
CONF_ADDON_DEVICE: self.usb_path,
CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key,
CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key,
CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key,
CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key,
CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key,
CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key,
}
await self._async_set_addon_config(addon_config_updates)
return await self.async_step_start_addon()
# Network already exists, go to security keys step
return await self.async_step_configure_security_keys()
return self.async_show_form(
step_id="network_type",
data_schema=vol.Schema(
{
vol.Required("network_type", default=""): vol.In(
[NETWORK_TYPE_NEW, NETWORK_TYPE_EXISTING]
)
}
),
)
async def async_step_configure_security_keys(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask for security keys for existing Z-Wave network."""
addon_info = await self._async_get_addon_info()
addon_config = addon_info.options
@@ -652,10 +731,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or ""
)
if self._recommended_install and self._usb_discovery:
# Recommended installation with USB discovery, skip asking for keys
user_input = {}
if user_input is not None:
self.s0_legacy_key = user_input.get(CONF_S0_LEGACY_KEY, s0_legacy_key)
self.s2_access_control_key = user_input.get(
@@ -673,8 +748,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
self.lr_s2_authenticated_key = user_input.get(
CONF_LR_S2_AUTHENTICATED_KEY, lr_s2_authenticated_key
)
if not self._usb_discovery:
self.usb_path = user_input[CONF_USB_PATH]
addon_config_updates = {
CONF_ADDON_DEVICE: self.usb_path,
@@ -687,14 +760,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
}
await self._async_set_addon_config(addon_config_updates)
return await self.async_step_start_addon()
usb_path = self.usb_path or addon_config.get(CONF_ADDON_DEVICE) or ""
schema: VolDictType = (
{}
if self._recommended_install
else {
data_schema = vol.Schema(
{
vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str,
vol.Optional(
CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key
@@ -714,22 +783,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
}
)
if not self._usb_discovery:
try:
ports = await async_get_usb_ports(self.hass)
except OSError as err:
_LOGGER.error("Failed to get USB ports: %s", err)
return self.async_abort(reason="usb_ports_failed")
schema = {
vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports),
**schema,
}
data_schema = vol.Schema(schema)
return self.async_show_form(
step_id="configure_addon_user", data_schema=data_schema
step_id="configure_security_keys", data_schema=data_schema
)
async def async_step_finish_addon_setup_user(
@@ -843,11 +898,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
if user_input is not None:
self._migrating = True
return await self.async_step_backup_nvm()
return self.async_show_form(step_id="intent_migrate")
self._migrating = True
return await self.async_step_backup_nvm()
async def async_step_backup_nvm(
self, user_input: dict[str, Any] | None = None
@@ -902,7 +954,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_instruct_unplug(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reset the current controller, and instruct the user to unplug it."""
"""Instruct the user to unplug the old controller."""
if user_input is not None:
if self.usb_path:
@@ -912,63 +964,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
# Now that the old controller is gone, we can scan for serial ports again
return await self.async_step_choose_serial_port()
try:
driver = self._get_driver()
except AbortFlow:
return self.async_abort(reason="config_entry_not_loaded")
@callback
def set_driver_ready(event: dict) -> None:
"Set the driver ready event."
wait_driver_ready.set()
wait_driver_ready = asyncio.Event()
unsubscribe = driver.once("driver ready", set_driver_ready)
# reset the old controller
try:
await driver.async_hard_reset()
except FailedCommand as err:
unsubscribe()
_LOGGER.error("Failed to reset controller: %s", err)
return self.async_abort(reason="reset_failed")
# Update the unique id of the config entry
# to the new home id, which requires waiting for the driver
# to be ready before getting the new home id.
# If the backup restore, done later in the flow, fails,
# the config entry unique id should be the new home id
# after the controller reset.
try:
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait()
except TimeoutError:
pass
finally:
unsubscribe()
config_entry = self._reconfigure_config_entry
assert config_entry is not None
try:
version_info = await async_get_version_info(
self.hass, config_entry.data[CONF_URL]
)
except CannotConnect:
# Just log this error, as there's nothing to do about it here.
# The stale unique id needs to be handled by a repair flow,
# after the config entry has been reloaded, if the backup restore
# also fails.
_LOGGER.debug(
"Failed to get server version, cannot update config entry "
"unique id with new home id, after controller reset"
)
else:
self.hass.config_entries.async_update_entry(
config_entry, unique_id=str(version_info.home_id)
)
# Unload the config entry before asking the user to unplug the controller.
await self.hass.config_entries.async_unload(config_entry.entry_id)
@@ -1400,7 +1398,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
driver.once("driver ready", set_driver_ready),
]
try:
await controller.async_restore_nvm(self.backup_data)
await controller.async_restore_nvm(
self.backup_data, {"preserveRoutes": False}
)
except FailedCommand as err:
raise AbortFlow(f"Failed to restore network: {err}") from err
else:
@@ -9,7 +9,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["zwave_js_server"],
"requirements": ["pyserial==3.5", "zwave-js-server-python==0.64.0"],
"requirements": ["pyserial==3.5", "zwave-js-server-python==0.65.0"],
"usb": [
{
"vid": "0658",
+33 -17
View File
@@ -39,25 +39,37 @@
"step": {
"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",
"s2_unauthenticated_key": "S2 Unauthenticated Key",
"usb_path": "[%key:common::config_flow::data::usb_path%]"
},
"description": "Select your Z-Wave adapter",
"title": "Enter the Z-Wave add-on configuration"
},
"network_type": {
"data": {
"network_type": "Is your network new or does it already exist?"
},
"title": "Z-Wave network"
},
"configure_security_keys": {
"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",
"s2_unauthenticated_key": "S2 Unauthenticated Key"
},
"description": "Enter the security keys for your existing Z-Wave network",
"title": "Security keys"
},
"configure_addon_reconfigure": {
"data": {
"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%]",
"lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::lr_s2_access_control_key%]",
"lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::lr_s2_authenticated_key%]",
"s0_legacy_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s0_legacy_key%]",
"s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_access_control_key%]",
"s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_authenticated_key%]",
"s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_unauthenticated_key%]",
"usb_path": "[%key:common::config_flow::data::usb_path%]"
},
"description": "[%key:component::zwave_js::config::step::configure_addon_user::description%]",
@@ -108,13 +120,9 @@
"intent_reconfigure": "Re-configure the current controller"
}
},
"intent_migrate": {
"title": "[%key:component::zwave_js::config::step::reconfigure::menu_options::intent_migrate%]",
"description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?"
},
"instruct_unplug": {
"title": "Unplug your old controller",
"description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing."
"description": "Backup saved to \"{file_path}\"\n\nYour old controller has not been reset. You should now unplug it to prevent it from interfering with the new controller.\n\nPlease make sure your new controller is plugged in before continuing."
},
"restore_failed": {
"title": "Restoring unsuccessful",
@@ -626,5 +634,13 @@
},
"name": "Set a value (advanced)"
}
},
"selector": {
"network_type": {
"options": {
"new": "It's new",
"existing": "It already exists"
}
}
}
}
+6
View File
@@ -1646,6 +1646,7 @@ class ConfigEntriesFlowManager(
report_usage(
"creates a config entry when another entry with the same unique ID "
"exists",
breaks_in_ha_version="2026.3",
core_behavior=ReportBehavior.LOG,
core_integration_behavior=ReportBehavior.LOG,
custom_integration_behavior=ReportBehavior.LOG,
@@ -3420,6 +3421,11 @@ class ConfigSubentryFlow(
"""Return config entry id."""
return self.handler[0]
@property
def _subentry_type(self) -> str:
"""Return type of subentry we are editing/creating."""
return self.handler[1]
@callback
def _get_entry(self) -> ConfigEntry:
"""Return the config entry linked to the current context."""
+1 -1
View File
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 7
PATCH_VERSION: Final = "0.dev0"
PATCH_VERSION: Final = "0b3"
__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 -8
View File
@@ -1483,12 +1483,6 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"dweet": {
"name": "dweet.io",
"integration_type": "hub",
"config_flow": false,
"iot_class": "cloud_polling"
},
"eafm": {
"name": "Environment Agency Flood Gauges",
"integration_type": "hub",
@@ -3165,8 +3159,7 @@
"name": "Jellyfin",
"integration_type": "service",
"config_flow": true,
"iot_class": "local_polling",
"single_config_entry": true
"iot_class": "local_polling"
},
"jewish_calendar": {
"name": "Jewish Calendar",
+1 -1
View File
@@ -38,7 +38,7 @@ habluetooth==3.49.0
hass-nabucasa==0.104.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250625.0
home-assistant-frontend==20250627.0
home-assistant-intents==2025.6.23
httpx==0.28.1
ifaddr==0.2.0
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.7.0.dev0"
version = "2025.7.0b3"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+7 -10
View File
@@ -7,7 +7,7 @@
AEMET-OpenData==0.6.4
# homeassistant.components.honeywell
AIOSomecomfort==0.0.32
AIOSomecomfort==0.0.33
# homeassistant.components.adax
Adax-local==0.1.5
@@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12
aioairzone==1.0.0
# homeassistant.components.alexa_devices
aioamazondevices==3.1.19
aioamazondevices==3.1.22
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -820,9 +820,6 @@ dsmr-parser==1.4.3
# homeassistant.components.dwd_weather_warnings
dwdwfsapi==1.0.7
# homeassistant.components.dweet
dweepy==0.3.0
# homeassistant.components.dynalite
dynalite-devices==0.1.47
@@ -1171,7 +1168,7 @@ hole==0.8.0
holidays==0.75
# homeassistant.components.frontend
home-assistant-frontend==20250625.0
home-assistant-frontend==20250627.0
# homeassistant.components.conversation
home-assistant-intents==2025.6.23
@@ -1279,7 +1276,7 @@ israel-rail-api==0.1.2
jaraco.abode==6.2.1
# homeassistant.components.jellyfin
jellyfin-apiclient-python==1.10.0
jellyfin-apiclient-python==1.11.0
# homeassistant.components.command_line
# homeassistant.components.rest
@@ -1817,7 +1814,7 @@ pySDCP==1
pyTibber==0.31.2
# homeassistant.components.dlink
pyW215==0.7.0
pyW215==0.8.0
# homeassistant.components.w800rf32
pyW800rf32==0.4
@@ -2166,7 +2163,7 @@ pymsteams==0.1.12
pymysensors==0.25.0
# homeassistant.components.iron_os
pynecil==4.1.0
pynecil==4.1.1
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -3205,7 +3202,7 @@ ziggo-mediabox-xl==1.1.0
zm-py==0.5.4
# homeassistant.components.zwave_js
zwave-js-server-python==0.64.0
zwave-js-server-python==0.65.0
# homeassistant.components.zwave_me
zwave-me-ws==0.4.3
+7 -7
View File
@@ -7,7 +7,7 @@
AEMET-OpenData==0.6.4
# homeassistant.components.honeywell
AIOSomecomfort==0.0.32
AIOSomecomfort==0.0.33
# homeassistant.components.adax
Adax-local==0.1.5
@@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12
aioairzone==1.0.0
# homeassistant.components.alexa_devices
aioamazondevices==3.1.19
aioamazondevices==3.1.22
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -1017,7 +1017,7 @@ hole==0.8.0
holidays==0.75
# homeassistant.components.frontend
home-assistant-frontend==20250625.0
home-assistant-frontend==20250627.0
# homeassistant.components.conversation
home-assistant-intents==2025.6.23
@@ -1107,7 +1107,7 @@ israel-rail-api==0.1.2
jaraco.abode==6.2.1
# homeassistant.components.jellyfin
jellyfin-apiclient-python==1.10.0
jellyfin-apiclient-python==1.11.0
# homeassistant.components.command_line
# homeassistant.components.rest
@@ -1525,7 +1525,7 @@ pyRFXtrx==0.31.1
pyTibber==0.31.2
# homeassistant.components.dlink
pyW215==0.7.0
pyW215==0.8.0
# homeassistant.components.hisense_aehw4a1
pyaehw4a1==0.3.9
@@ -1799,7 +1799,7 @@ pymonoprice==0.4
pymysensors==0.25.0
# homeassistant.components.iron_os
pynecil==4.1.0
pynecil==4.1.1
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -2637,7 +2637,7 @@ zeversolar==0.3.2
zha==0.0.61
# homeassistant.components.zwave_js
zwave-js-server-python==0.64.0
zwave-js-server-python==0.65.0
# homeassistant.components.zwave_me
zwave-me-ws==0.4.3
@@ -0,0 +1,56 @@
"""Tests for Alexa Devices utils."""
from unittest.mock import AsyncMock
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
import pytest
from homeassistant.components.alexa_devices.const import DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import setup_integration
from tests.common import MockConfigEntry
ENTITY_ID = "switch.echo_test_do_not_disturb"
@pytest.mark.parametrize(
("side_effect", "key", "error"),
[
(CannotConnect, "cannot_connect", "CannotConnect()"),
(CannotRetrieveData, "cannot_retrieve_data", "CannotRetrieveData()"),
],
)
async def test_alexa_api_call_exceptions(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
side_effect: Exception,
key: str,
error: str,
) -> None:
"""Test alexa_api_call decorator for exceptions."""
await setup_integration(hass, mock_config_entry)
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_OFF
mock_amazon_devices_client.set_do_not_disturb.side_effect = side_effect
# Call API
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == key
assert exc_info.value.translation_placeholders == {"error": error}
+10
View File
@@ -141,6 +141,10 @@ async def test_migration_from_v1_to_v2(
)
assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)}
assert migrated_device.id == device.id
assert migrated_device.config_entries == {mock_config_entry.entry_id}
assert migrated_device.config_entries_subentries == {
mock_config_entry.entry_id: {subentry.subentry_id}
}
async def test_migration_from_v1_to_v2_with_multiple_keys(
@@ -231,6 +235,8 @@ async def test_migration_from_v1_to_v2_with_multiple_keys(
identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)}
)
assert dev is not None
assert dev.config_entries == {entry.entry_id}
assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}}
async def test_migration_from_v1_to_v2_with_same_keys(
@@ -329,3 +335,7 @@ async def test_migration_from_v1_to_v2_with_same_keys(
identifiers={(DOMAIN, subentry.subentry_id)}
)
assert dev is not None
assert dev.config_entries == {mock_config_entry.entry_id}
assert dev.config_entries_subentries == {
mock_config_entry.entry_id: {subentry.subentry_id}
}
@@ -8,6 +8,7 @@ import pytest
from homeassistant.components.google_generative_ai_conversation.const import (
CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_CONVERSATION_NAME,
DEFAULT_TTS_NAME,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API
@@ -33,8 +34,16 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"data": {},
"subentry_type": "conversation",
"title": DEFAULT_CONVERSATION_NAME,
"subentry_id": "ulid-conversation",
"unique_id": None,
}
},
{
"data": {},
"subentry_type": "tts",
"title": DEFAULT_TTS_NAME,
"subentry_id": "ulid-tts",
"unique_id": None,
},
],
)
entry.runtime_data = Mock()
@@ -5,17 +5,35 @@
'api_key': '**REDACTED**',
}),
'options': dict({
'chat_model': 'models/gemini-2.5-flash',
'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
'max_tokens': 1500,
'prompt': 'Speak like a pirate',
'recommended': False,
'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
'temperature': 1.0,
'top_k': 64,
'top_p': 0.95,
}),
'subentries': dict({
'ulid-conversation': dict({
'data': dict({
'chat_model': 'models/gemini-2.5-flash',
'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
'max_tokens': 1500,
'prompt': 'Speak like a pirate',
'recommended': False,
'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
'temperature': 1.0,
'top_k': 64,
'top_p': 0.95,
}),
'subentry_id': 'ulid-conversation',
'subentry_type': 'conversation',
'title': 'Google AI Conversation',
'unique_id': None,
}),
'ulid-tts': dict({
'data': dict({
}),
'subentry_id': 'ulid-tts',
'subentry_type': 'tts',
'title': 'Google AI TTS',
'unique_id': None,
}),
}),
'title': 'Google Generative AI Conversation',
})
@@ -1,4 +1,70 @@
# serializer version: 1
# name: test_devices
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'google_generative_ai_conversation',
'ulid-conversation',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Google',
'model': 'gemini-2.5-flash',
'model_id': None,
'name': 'Google AI Conversation',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
}),
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'google_generative_ai_conversation',
'ulid-tts',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Google',
'model': 'gemini-2.5-flash-preview-tts',
'model_id': None,
'name': 'Google AI TTS',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_generate_content_file_processing_succeeds
list([
tuple(
@@ -6,9 +6,6 @@ import pytest
from requests.exceptions import Timeout
from homeassistant import config_entries
from homeassistant.components.google_generative_ai_conversation.config_flow import (
RECOMMENDED_OPTIONS,
)
from homeassistant.components.google_generative_ai_conversation.const import (
CONF_CHAT_MODEL,
CONF_DANGEROUS_BLOCK_THRESHOLD,
@@ -23,12 +20,15 @@ from homeassistant.components.google_generative_ai_conversation.const import (
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_CONVERSATION_NAME,
DEFAULT_TTS_NAME,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
RECOMMENDED_TTS_OPTIONS,
RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
)
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
@@ -115,10 +115,16 @@ async def test_form(hass: HomeAssistant) -> None:
assert result2["subentries"] == [
{
"subentry_type": "conversation",
"data": RECOMMENDED_OPTIONS,
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
}
},
{
"subentry_type": "tts",
"data": RECOMMENDED_TTS_OPTIONS,
"title": DEFAULT_TTS_NAME,
"unique_id": None,
},
]
assert len(mock_setup_entry.mock_calls) == 1
@@ -172,19 +178,64 @@ async def test_creating_conversation_subentry(
):
result2 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS},
{CONF_NAME: "Mock name", **RECOMMENDED_CONVERSATION_OPTIONS},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Mock name"
processed_options = RECOMMENDED_OPTIONS.copy()
processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip()
assert result2["data"] == processed_options
async def test_creating_tts_subentry(
hass: HomeAssistant,
mock_init_component: None,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test creating a TTS subentry."""
with patch(
"google.genai.models.AsyncModels.list",
return_value=get_models_pager(),
):
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "tts"),
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM, result
assert result["step_id"] == "set_options"
assert not result["errors"]
old_subentries = set(mock_config_entry.subentries)
with patch(
"google.genai.models.AsyncModels.list",
return_value=get_models_pager(),
):
result2 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{CONF_NAME: "Mock TTS", **RECOMMENDED_TTS_OPTIONS},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Mock TTS"
assert result2["data"] == RECOMMENDED_TTS_OPTIONS
assert len(mock_config_entry.subentries) == 3
new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0]
new_subentry = mock_config_entry.subentries[new_subentry_id]
assert new_subentry.subentry_type == "tts"
assert new_subentry.data == RECOMMENDED_TTS_OPTIONS
assert new_subentry.title == "Mock TTS"
async def test_creating_conversation_subentry_not_loaded(
hass: HomeAssistant,
mock_init_component: None,
@@ -35,10 +35,10 @@ async def test_diagnostics(
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(
hass.config_entries.async_update_subentry(
mock_config_entry,
options={
next(iter(mock_config_entry.subentries.values())),
data={
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_TEMPERATURE: RECOMMENDED_TEMPERATURE,
@@ -7,7 +7,12 @@ import pytest
from requests.exceptions import Timeout
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.google_generative_ai_conversation.const import DOMAIN
from homeassistant.components.google_generative_ai_conversation.const import (
DEFAULT_TITLE,
DEFAULT_TTS_NAME,
DOMAIN,
RECOMMENDED_TTS_OPTIONS,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
@@ -469,13 +474,28 @@ async def test_migration_from_v1_to_v2(
entry = entries[0]
assert entry.version == 2
assert not entry.options
assert len(entry.subentries) == 2
for subentry in entry.subentries.values():
assert entry.title == DEFAULT_TITLE
assert len(entry.subentries) == 3
conversation_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "conversation"
]
assert len(conversation_subentries) == 2
for subentry in conversation_subentries:
assert subentry.subentry_type == "conversation"
assert subentry.data == options
assert "Google Generative AI" in subentry.title
tts_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "tts"
]
assert len(tts_subentries) == 1
assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS
assert tts_subentries[0].title == DEFAULT_TTS_NAME
subentry = list(entry.subentries.values())[0]
subentry = conversation_subentries[0]
entity = entity_registry.async_get("conversation.google_generative_ai_conversation")
assert entity.unique_id == subentry.subentry_id
@@ -492,8 +512,12 @@ async def test_migration_from_v1_to_v2(
)
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
assert device.id == device_1.id
assert device.config_entries == {mock_config_entry.entry_id}
assert device.config_entries_subentries == {
mock_config_entry.entry_id: {subentry.subentry_id}
}
subentry = list(entry.subentries.values())[1]
subentry = conversation_subentries[1]
entity = entity_registry.async_get(
"conversation.google_generative_ai_conversation_2"
@@ -511,6 +535,10 @@ async def test_migration_from_v1_to_v2(
)
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
assert device.id == device_2.id
assert device.config_entries == {mock_config_entry.entry_id}
assert device.config_entries_subentries == {
mock_config_entry.entry_id: {subentry.subentry_id}
}
async def test_migration_from_v1_to_v2_with_multiple_keys(
@@ -591,16 +619,25 @@ async def test_migration_from_v1_to_v2_with_multiple_keys(
for entry in entries:
assert entry.version == 2
assert not entry.options
assert len(entry.subentries) == 1
assert entry.title == DEFAULT_TITLE
assert len(entry.subentries) == 2
subentry = list(entry.subentries.values())[0]
assert subentry.subentry_type == "conversation"
assert subentry.data == options
assert "Google Generative AI" in subentry.title
subentry = list(entry.subentries.values())[1]
assert subentry.subentry_type == "tts"
assert subentry.data == RECOMMENDED_TTS_OPTIONS
assert subentry.title == DEFAULT_TTS_NAME
dev = device_registry.async_get_device(
identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)}
)
assert dev is not None
assert dev.config_entries == {entry.entry_id}
assert dev.config_entries_subentries == {
entry.entry_id: {list(entry.subentries.values())[0].subentry_id}
}
async def test_migration_from_v1_to_v2_with_same_keys(
@@ -680,13 +717,28 @@ async def test_migration_from_v1_to_v2_with_same_keys(
entry = entries[0]
assert entry.version == 2
assert not entry.options
assert len(entry.subentries) == 2
for subentry in entry.subentries.values():
assert entry.title == DEFAULT_TITLE
assert len(entry.subentries) == 3
conversation_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "conversation"
]
assert len(conversation_subentries) == 2
for subentry in conversation_subentries:
assert subentry.subentry_type == "conversation"
assert subentry.data == options
assert "Google Generative AI" in subentry.title
tts_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "tts"
]
assert len(tts_subentries) == 1
assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS
assert tts_subentries[0].title == DEFAULT_TTS_NAME
subentry = list(entry.subentries.values())[0]
subentry = conversation_subentries[0]
entity = entity_registry.async_get("conversation.google_generative_ai_conversation")
assert entity.unique_id == subentry.subentry_id
@@ -703,8 +755,12 @@ async def test_migration_from_v1_to_v2_with_same_keys(
)
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
assert device.id == device_1.id
assert device.config_entries == {mock_config_entry.entry_id}
assert device.config_entries_subentries == {
mock_config_entry.entry_id: {subentry.subentry_id}
}
subentry = list(entry.subentries.values())[1]
subentry = conversation_subentries[1]
entity = entity_registry.async_get(
"conversation.google_generative_ai_conversation_2"
@@ -722,3 +778,21 @@ async def test_migration_from_v1_to_v2_with_same_keys(
)
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
assert device.id == device_2.id
assert device.config_entries == {mock_config_entry.entry_id}
assert device.config_entries_subentries == {
mock_config_entry.entry_id: {subentry.subentry_id}
}
async def test_devices(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Assert that devices are created correctly."""
devices = dr.async_entries_for_config_entry(
device_registry, mock_config_entry.entry_id
)
assert devices == snapshot
@@ -9,30 +9,37 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from google.genai import types
from google.genai.errors import APIError
import pytest
from homeassistant.components import tts
from homeassistant.components.google_generative_ai_conversation.tts import (
ATTR_MODEL,
from homeassistant.components.google_generative_ai_conversation.const import (
CONF_CHAT_MODEL,
DOMAIN,
RECOMMENDED_TTS_MODEL,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
)
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
DOMAIN as DOMAIN_MP,
SERVICE_PLAY_MEDIA,
)
from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_PLATFORM
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core_config import async_process_ha_core_config
from homeassistant.setup import async_setup_component
from . import API_ERROR_500
from tests.common import MockConfigEntry, async_mock_service
from tests.components.tts.common import retrieve_media
from tests.typing import ClientSessionGenerator
API_ERROR_500 = APIError("test", response=MagicMock())
TEST_CHAT_MODEL = "models/some-tts-model"
@pytest.fixture(autouse=True)
def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None:
@@ -63,20 +70,22 @@ def mock_genai_client() -> Generator[AsyncMock]:
"""Mock genai_client."""
client = Mock()
client.aio.models.get = AsyncMock()
client.models.generate_content.return_value = types.GenerateContentResponse(
candidates=(
types.Candidate(
content=types.Content(
parts=(
types.Part(
inline_data=types.Blob(
data=b"raw-audio-bytes",
mime_type="audio/L16;rate=24000",
)
),
client.aio.models.generate_content = AsyncMock(
return_value=types.GenerateContentResponse(
candidates=(
types.Candidate(
content=types.Content(
parts=(
types.Part(
inline_data=types.Blob(
data=b"raw-audio-bytes",
mime_type="audio/L16;rate=24000",
)
),
)
)
)
),
),
)
)
)
with patch(
@@ -90,17 +99,29 @@ def mock_genai_client() -> Generator[AsyncMock]:
async def setup_fixture(
hass: HomeAssistant,
config: dict[str, Any],
request: pytest.FixtureRequest,
mock_genai_client: AsyncMock,
) -> None:
"""Set up the test environment."""
if request.param == "mock_setup":
await mock_setup(hass, config)
if request.param == "mock_config_entry_setup":
await mock_config_entry_setup(hass, config)
else:
raise RuntimeError("Invalid setup fixture")
config_entry = MockConfigEntry(domain=DOMAIN, data=config, version=2)
config_entry.add_to_hass(hass)
sub_entry = ConfigSubentry(
data={
tts.CONF_LANG: "en-US",
CONF_CHAT_MODEL: TEST_CHAT_MODEL,
},
subentry_type="tts",
title="Google AI TTS",
subentry_id="test_subentry_tts_id",
unique_id=None,
)
config_entry.runtime_data = mock_genai_client
hass.config_entries.async_add_subentry(config_entry, sub_entry)
await hass.config_entries.async_setup(config_entry.entry_id)
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
@@ -112,105 +133,38 @@ def config_fixture() -> dict[str, Any]:
}
async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None:
"""Mock setup."""
assert await async_setup_component(
hass, tts.DOMAIN, {tts.DOMAIN: {CONF_PLATFORM: DOMAIN} | config}
)
async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None:
"""Mock config entry setup."""
default_config = {tts.CONF_LANG: "en-US"}
config_entry = MockConfigEntry(
domain=DOMAIN, data=default_config | config, version=2
)
client_mock = Mock()
client_mock.models.get = None
client_mock.models.generate_content.return_value = types.GenerateContentResponse(
candidates=(
types.Candidate(
content=types.Content(
parts=(
types.Part(
inline_data=types.Blob(
data=b"raw-audio-bytes",
mime_type="audio/L16;rate=24000",
)
),
)
)
),
)
)
config_entry.runtime_data = client_mock
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
@pytest.mark.parametrize(
("setup", "tts_service", "service_data"),
"service_data",
[
(
"mock_config_entry_setup",
"speak",
{
ATTR_ENTITY_ID: "tts.google_generative_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_OPTIONS: {},
},
),
(
"mock_config_entry_setup",
"speak",
{
ATTR_ENTITY_ID: "tts.google_generative_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"},
},
),
(
"mock_config_entry_setup",
"speak",
{
ATTR_ENTITY_ID: "tts.google_generative_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"},
},
),
(
"mock_config_entry_setup",
"speak",
{
ATTR_ENTITY_ID: "tts.google_generative_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"},
},
),
{
ATTR_ENTITY_ID: "tts.google_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_OPTIONS: {},
},
{
ATTR_ENTITY_ID: "tts.google_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"},
},
],
indirect=["setup"],
)
@pytest.mark.usefixtures("setup")
async def test_tts_service_speak(
setup: AsyncMock,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
calls: list[ServiceCall],
tts_service: str,
service_data: dict[str, Any],
) -> None:
"""Test tts service."""
tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID])
tts_entity._genai_client.models.generate_content.reset_mock()
tts_entity._genai_client.aio.models.generate_content.reset_mock()
await hass.services.async_call(
tts.DOMAIN,
tts_service,
"speak",
service_data,
blocking=True,
)
@@ -221,10 +175,9 @@ async def test_tts_service_speak(
== HTTPStatus.OK
)
voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "zephyr")
model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, RECOMMENDED_TTS_MODEL)
tts_entity._genai_client.models.generate_content.assert_called_once_with(
model=model_id,
tts_entity._genai_client.aio.models.generate_content.assert_called_once_with(
model=TEST_CHAT_MODEL,
contents="There is a person at the front door.",
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
@@ -233,109 +186,52 @@ async def test_tts_service_speak(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id)
)
),
temperature=RECOMMENDED_TEMPERATURE,
top_k=RECOMMENDED_TOP_K,
top_p=RECOMMENDED_TOP_P,
max_output_tokens=RECOMMENDED_MAX_TOKENS,
safety_settings=[
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
],
),
)
@pytest.mark.parametrize(
("setup", "tts_service", "service_data"),
[
(
"mock_config_entry_setup",
"speak",
{
ATTR_ENTITY_ID: "tts.google_generative_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_LANGUAGE: "de-DE",
tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"},
},
),
(
"mock_config_entry_setup",
"speak",
{
ATTR_ENTITY_ID: "tts.google_generative_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_LANGUAGE: "it-IT",
tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"},
},
),
],
indirect=["setup"],
)
async def test_tts_service_speak_lang_config(
setup: AsyncMock,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
calls: list[ServiceCall],
tts_service: str,
service_data: dict[str, Any],
) -> None:
"""Test service call with languages in the config."""
tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID])
tts_entity._genai_client.models.generate_content.reset_mock()
await hass.services.async_call(
tts.DOMAIN,
tts_service,
service_data,
blocking=True,
)
assert len(calls) == 1
assert (
await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID])
== HTTPStatus.OK
)
tts_entity._genai_client.models.generate_content.assert_called_once_with(
model=RECOMMENDED_TTS_MODEL,
contents="There is a person at the front door.",
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1")
)
),
),
)
@pytest.mark.parametrize(
("setup", "tts_service", "service_data"),
[
(
"mock_config_entry_setup",
"speak",
{
ATTR_ENTITY_ID: "tts.google_generative_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"},
},
),
],
indirect=["setup"],
)
@pytest.mark.usefixtures("setup")
async def test_tts_service_speak_error(
setup: AsyncMock,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
calls: list[ServiceCall],
tts_service: str,
service_data: dict[str, Any],
) -> None:
"""Test service call with HTTP response 500."""
service_data = {
ATTR_ENTITY_ID: "tts.google_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"},
}
tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID])
tts_entity._genai_client.models.generate_content.reset_mock()
tts_entity._genai_client.models.generate_content.side_effect = API_ERROR_500
tts_entity._genai_client.aio.models.generate_content.reset_mock()
tts_entity._genai_client.aio.models.generate_content.side_effect = API_ERROR_500
await hass.services.async_call(
tts.DOMAIN,
tts_service,
"speak",
service_data,
blocking=True,
)
@@ -346,70 +242,39 @@ async def test_tts_service_speak_error(
== HTTPStatus.INTERNAL_SERVER_ERROR
)
tts_entity._genai_client.models.generate_content.assert_called_once_with(
model=RECOMMENDED_TTS_MODEL,
voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE)
tts_entity._genai_client.aio.models.generate_content.assert_called_once_with(
model=TEST_CHAT_MODEL,
contents="There is a person at the front door.",
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1")
)
),
),
)
@pytest.mark.parametrize(
("setup", "tts_service", "service_data"),
[
(
"mock_config_entry_setup",
"speak",
{
ATTR_ENTITY_ID: "tts.google_generative_ai_tts",
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
tts.ATTR_MESSAGE: "There is a person at the front door.",
tts.ATTR_OPTIONS: {},
},
),
],
indirect=["setup"],
)
async def test_tts_service_speak_without_options(
setup: AsyncMock,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
calls: list[ServiceCall],
tts_service: str,
service_data: dict[str, Any],
) -> None:
"""Test service call with HTTP response 200."""
tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID])
tts_entity._genai_client.models.generate_content.reset_mock()
await hass.services.async_call(
tts.DOMAIN,
tts_service,
service_data,
blocking=True,
)
assert len(calls) == 1
assert (
await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID])
== HTTPStatus.OK
)
tts_entity._genai_client.models.generate_content.assert_called_once_with(
model=RECOMMENDED_TTS_MODEL,
contents="There is a person at the front door.",
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="zephyr")
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id)
)
),
temperature=RECOMMENDED_TEMPERATURE,
top_k=RECOMMENDED_TOP_K,
top_p=RECOMMENDED_TOP_P,
max_output_tokens=RECOMMENDED_MAX_TOKENS,
safety_settings=[
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
],
),
)
@@ -6,6 +6,7 @@ import contextlib
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
from aiohttp import ClientError
from ha_silabs_firmware_client import (
FirmwareManifest,
FirmwareMetadata,
@@ -80,7 +81,7 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN):
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="confirm_zigbee",
next_step_id="pre_confirm_zigbee",
)
async def async_step_install_thread_firmware(
@@ -137,7 +138,7 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
return await self.async_step_confirm_zigbee()
return await self.async_step_pre_confirm_zigbee()
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
@@ -208,6 +209,7 @@ def mock_firmware_info(
*,
is_hassio: bool = True,
probe_app_type: ApplicationType | None = ApplicationType.EZSP,
probe_fw_version: str | None = "2.4.4.0",
otbr_addon_info: AddonInfo = AddonInfo(
available=True,
hostname=None,
@@ -217,6 +219,7 @@ def mock_firmware_info(
version=None,
),
flash_app_type: ApplicationType = ApplicationType.EZSP,
flash_fw_version: str | None = "7.4.4.0",
) -> Iterator[tuple[Mock, Mock]]:
"""Mock the main addon states for the config flow."""
mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass))
@@ -243,7 +246,14 @@ def mock_firmware_info(
checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
size=123,
release_notes="Some release notes",
metadata={},
metadata={
"baudrate": 460800,
"fw_type": "openthread_rcp",
"fw_variant": None,
"metadata_version": 2,
"ot_rcp_version": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4",
"sdk_version": "4.4.4",
},
url=TEST_RELEASES_URL / "fake_openthread_rcp_7.4.4.0_variant.gbl",
),
FirmwareMetadata(
@@ -251,7 +261,14 @@ def mock_firmware_info(
checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
size=123,
release_notes="Some release notes",
metadata={},
metadata={
"baudrate": 115200,
"ezsp_version": "7.4.4.0",
"fw_type": "zigbee_ncp",
"fw_variant": None,
"metadata_version": 2,
"sdk_version": "4.4.4",
},
url=TEST_RELEASES_URL / "fake_zigbee_ncp_7.4.4.0_variant.gbl",
),
],
@@ -263,7 +280,7 @@ def mock_firmware_info(
probed_firmware_info = FirmwareInfo(
device="/dev/ttyUSB0", # Not used
firmware_type=probe_app_type,
firmware_version=None,
firmware_version=probe_fw_version,
owners=[],
source="probe",
)
@@ -274,7 +291,7 @@ def mock_firmware_info(
flashed_firmware_info = FirmwareInfo(
device=TEST_DEVICE,
firmware_type=flash_app_type,
firmware_version="7.4.4.0",
firmware_version=flash_fw_version,
owners=[create_mock_owner()],
source="probe",
)
@@ -333,7 +350,7 @@ def mock_firmware_info(
side_effect=mock_flash_firmware,
),
):
yield mock_otbr_manager
yield mock_otbr_manager, mock_update_client
async def consume_progress_flow(
@@ -411,6 +428,91 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None:
assert zha_flow["step_id"] == "confirm"
async def test_config_flow_firmware_index_download_fails_but_not_required(
hass: HomeAssistant,
) -> None:
"""Test flow continues if index download fails but install is not required."""
init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with mock_firmware_info(
hass,
# The correct firmware is already installed
probe_app_type=ApplicationType.EZSP,
# An older version is probed, so an upgrade is attempted
probe_fw_version="7.4.3.0",
) as (_, mock_update_client):
# Mock the firmware download to fail
mock_update_client.async_update_data.side_effect = ClientError()
pick_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
assert pick_result["type"] is FlowResultType.FORM
assert pick_result["step_id"] == "confirm_zigbee"
async def test_config_flow_firmware_download_fails_but_not_required(
hass: HomeAssistant,
) -> None:
"""Test flow continues if firmware download fails but install is not required."""
init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with (
mock_firmware_info(
hass,
# The correct firmware is already installed so installation isn't required
probe_app_type=ApplicationType.EZSP,
# An older version is probed, so an upgrade is attempted
probe_fw_version="7.4.3.0",
) as (_, mock_update_client),
):
mock_update_client.async_fetch_firmware.side_effect = ClientError()
pick_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
assert pick_result["type"] is FlowResultType.FORM
assert pick_result["step_id"] == "confirm_zigbee"
async def test_config_flow_doesnt_downgrade(
hass: HomeAssistant,
) -> None:
"""Test flow exits early, without downgrading firmware."""
init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with (
mock_firmware_info(
hass,
probe_app_type=ApplicationType.EZSP,
# An newer version is probed than what we offer
probe_fw_version="7.5.0.0",
),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware"
) as mock_async_flash_silabs_firmware,
):
pick_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
assert pick_result["type"] is FlowResultType.FORM
assert pick_result["step_id"] == "confirm_zigbee"
assert len(mock_async_flash_silabs_firmware.mock_calls) == 0
async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> None:
"""Test the config flow, skip installing the addon if necessary."""
result = await hass.config_entries.flow.async_init(
@@ -480,7 +582,7 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None:
hass,
probe_app_type=ApplicationType.EZSP,
flash_app_type=ApplicationType.SPINEL,
) as mock_otbr_manager:
) as (mock_otbr_manager, _):
# Pick the menu option
pick_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
@@ -564,7 +666,7 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) -
update_available=False,
version=None,
),
) as mock_otbr_manager:
) as (mock_otbr_manager, _):
# Pick the menu option
pick_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
@@ -631,7 +733,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None:
hass,
probe_app_type=ApplicationType.EZSP,
flash_app_type=ApplicationType.SPINEL,
) as mock_otbr_manager:
) as (mock_otbr_manager, _):
# First step is confirmation
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.MENU
@@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, patch
from aiohttp import ClientError
import pytest
from homeassistant.components.hassio import AddonError, AddonInfo, AddonState
@@ -109,7 +110,7 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None:
with mock_firmware_info(
hass,
probe_app_type=ApplicationType.EZSP,
) as mock_otbr_manager:
) as (mock_otbr_manager, _):
mock_otbr_manager.async_get_addon_info.side_effect = AddonError()
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
@@ -147,7 +148,7 @@ async def test_config_flow_thread_addon_already_configured(hass: HomeAssistant)
update_available=False,
version="1.0.0",
),
) as mock_otbr_manager:
) as (mock_otbr_manager, _):
mock_otbr_manager.async_install_addon_waiting = AsyncMock(
side_effect=AddonError()
)
@@ -178,7 +179,7 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No
with mock_firmware_info(
hass,
probe_app_type=ApplicationType.EZSP,
) as mock_otbr_manager:
) as (mock_otbr_manager, _):
mock_otbr_manager.async_install_addon_waiting = AsyncMock(
side_effect=AddonError()
)
@@ -209,7 +210,7 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) ->
with mock_firmware_info(
hass,
probe_app_type=ApplicationType.EZSP,
) as mock_otbr_manager:
) as (mock_otbr_manager, _):
async def install_addon() -> None:
mock_otbr_manager.async_get_addon_info.return_value = AddonInfo(
@@ -270,7 +271,7 @@ async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None
update_available=False,
version="1.0.0",
),
) as mock_otbr_manager:
) as (mock_otbr_manager, _):
mock_otbr_manager.async_start_addon_waiting = AsyncMock(
side_effect=AddonError()
)
@@ -341,6 +342,64 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non
assert pick_thread_progress_result["reason"] == "unsupported_firmware"
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains", ["test_firmware_domain"]
)
async def test_config_flow_firmware_index_download_fails_and_required(
hass: HomeAssistant,
) -> None:
"""Test flow aborts if OTA index download fails and install is required."""
init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with (
mock_firmware_info(
hass,
# The wrong firmware is installed, so a new install is required
probe_app_type=ApplicationType.SPINEL,
) as (_, mock_update_client),
):
mock_update_client.async_update_data.side_effect = ClientError()
pick_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
assert pick_result["type"] is FlowResultType.ABORT
assert pick_result["reason"] == "fw_download_failed"
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains", ["test_firmware_domain"]
)
async def test_config_flow_firmware_download_fails_and_required(
hass: HomeAssistant,
) -> None:
"""Test flow aborts if firmware download fails and install is required."""
init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with (
mock_firmware_info(
hass,
# The wrong firmware is installed, so a new install is required
probe_app_type=ApplicationType.SPINEL,
) as (_, mock_update_client),
):
mock_update_client.async_fetch_firmware.side_effect = ClientError()
pick_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
assert pick_result["type"] is FlowResultType.ABORT
assert pick_result["reason"] == "fw_download_failed"
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains",
["test_firmware_domain"],
@@ -75,7 +75,7 @@ async def test_config_flow(
next_step_id: str,
) -> ConfigFlowResult:
if next_step_id == "start_otbr_addon":
next_step_id = "confirm_otbr"
next_step_id = "pre_confirm_otbr"
return await getattr(self, f"async_step_{next_step_id}")(user_input={})
@@ -100,14 +100,22 @@ async def test_config_flow(
),
),
):
result = await hass.config_entries.flow.async_configure(
confirm_result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": step},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert confirm_result["type"] is FlowResultType.FORM
assert confirm_result["step_id"] == (
"confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr"
)
config_entry = result["result"]
create_result = await hass.config_entries.flow.async_configure(
confirm_result["flow_id"], user_input={}
)
assert create_result["type"] is FlowResultType.CREATE_ENTRY
config_entry = create_result["result"]
assert config_entry.data == {
"firmware": fw_type.value,
"firmware_version": fw_version,
@@ -171,7 +179,7 @@ async def test_options_flow(
assert result["description_placeholders"]["model"] == model
async def mock_async_step_pick_firmware_zigbee(self, data):
return await self.async_step_confirm_zigbee(user_input={})
return await self.async_step_pre_confirm_zigbee()
with (
patch(
@@ -190,13 +198,20 @@ async def test_options_flow(
),
),
):
result = await hass.config_entries.options.async_configure(
confirm_result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"] is True
assert confirm_result["type"] is FlowResultType.FORM
assert confirm_result["step_id"] == "confirm_zigbee"
create_result = await hass.config_entries.options.async_configure(
confirm_result["flow_id"], user_input={}
)
assert create_result["type"] is FlowResultType.CREATE_ENTRY
assert create_result["result"] is True
assert config_entry.data == {
"firmware": "ezsp",
@@ -348,7 +348,7 @@ async def test_firmware_options_flow(
assert result["description_placeholders"]["model"] == "Home Assistant Yellow"
async def mock_async_step_pick_firmware_zigbee(self, data):
return await self.async_step_confirm_zigbee(user_input={})
return await self.async_step_pre_confirm_zigbee()
async def mock_install_firmware_step(
self,
@@ -360,11 +360,16 @@ async def test_firmware_options_flow(
next_step_id: str,
) -> ConfigFlowResult:
if next_step_id == "start_otbr_addon":
next_step_id = "confirm_otbr"
next_step_id = "pre_confirm_otbr"
return await getattr(self, f"async_step_{next_step_id}")(user_input={})
with (
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee",
autospec=True,
side_effect=mock_async_step_pick_firmware_zigbee,
),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup",
return_value=None,
@@ -385,13 +390,22 @@ async def test_firmware_options_flow(
),
),
):
result = await hass.config_entries.options.async_configure(
confirm_result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"next_step_id": step},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"] is True
assert confirm_result["type"] is FlowResultType.FORM
assert confirm_result["step_id"] == (
"confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr"
)
create_result = await hass.config_entries.options.async_configure(
confirm_result["flow_id"], user_input={}
)
assert create_result["type"] is FlowResultType.CREATE_ENTRY
assert create_result["result"] is True
assert config_entry.data == {
"firmware": fw_type.value,
+7 -1
View File
@@ -305,16 +305,22 @@ async def test_auth_access_signed_path_with_refresh_token(
hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id
)
req = await client.head(signed_path)
assert req.status == HTTPStatus.OK
req = await client.get(signed_path)
assert req.status == HTTPStatus.OK
data = await req.json()
assert data["user_id"] == refresh_token.user.id
# Use signature on other path
req = await client.head(f"/another_path?{signed_path.split('?')[1]}")
assert req.status == HTTPStatus.UNAUTHORIZED
req = await client.get(f"/another_path?{signed_path.split('?')[1]}")
assert req.status == HTTPStatus.UNAUTHORIZED
# We only allow GET
# We only allow GET and HEAD
req = await client.post(signed_path)
assert req.status == HTTPStatus.UNAUTHORIZED
+21
View File
@@ -174,10 +174,22 @@ async def test_fetch_image_authenticated(
"""Test fetching an image with an authenticated client."""
client = await hass_client()
# Using HEAD
resp = await client.head("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.OK
assert resp.content_type == "image/jpeg"
assert resp.content_length == 4
resp = await client.head("/api/image_proxy/image.unknown")
assert resp.status == HTTPStatus.NOT_FOUND
# Using GET
resp = await client.get("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == b"Test"
assert resp.content_type == "image/jpeg"
assert resp.content_length == 4
resp = await client.get("/api/image_proxy/image.unknown")
assert resp.status == HTTPStatus.NOT_FOUND
@@ -260,10 +272,19 @@ async def test_fetch_image_url_success(
client = await hass_client()
# Using HEAD
resp = await client.head("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.OK
assert resp.content_type == "image/png"
assert resp.content_length == 4
# Using GET
resp = await client.get("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == b"Test"
assert resp.content_type == "image/png"
assert resp.content_length == 4
@respx.mock
@@ -1,5 +1,5 @@
{
"Id": "string",
"Id": "USER-UUID",
"ViewType": "string",
"SortBy": "string",
"IndexBy": "string",
+26 -11
View File
@@ -23,17 +23,6 @@ from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_abort_if_existing_entry(hass: HomeAssistant) -> None:
"""Check flow abort when an entry already exist."""
MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "single_instance_allowed"
async def test_form(
hass: HomeAssistant,
mock_jellyfin: MagicMock,
@@ -201,6 +190,32 @@ async def test_form_persists_device_id_on_error(
}
async def test_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_jellyfin: MagicMock,
mock_client: MagicMock,
) -> None:
"""Test the case where the user tries to configure an already configured entry."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=USER_INPUT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_reauth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
+26 -2
View File
@@ -2,11 +2,11 @@
from unittest.mock import MagicMock, patch
from pylamarzocco.const import ModelName
from pylamarzocco.const import MachineState, ModelName, WidgetType
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -52,3 +52,27 @@ async def test_steam_ready_entity_for_all_machines(
entry = entity_registry.async_get(state.entity_id)
assert entry
async def test_sensors_unavailable_if_machine_off(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the La Marzocco switches are unavailable when the device is offline."""
SWITCHES_UNAVAILABLE = (
("sensor.gs012345_steam_boiler_ready_time", True),
("sensor.gs012345_coffee_boiler_ready_time", True),
("sensor.gs012345_total_coffees_made", False),
)
mock_lamarzocco.dashboard.config[
WidgetType.CM_MACHINE_STATUS
].status = MachineState.OFF
with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]):
await async_init_integration(hass, mock_config_entry)
for sensor, available in SWITCHES_UNAVAILABLE:
state = hass.states.get(sensor)
assert state
assert (state.state == STATE_UNAVAILABLE) == available
+24 -2
View File
@@ -3,7 +3,7 @@
from typing import Any
from unittest.mock import MagicMock, patch
from pylamarzocco.const import SmartStandByType
from pylamarzocco.const import MachineState, SmartStandByType, WidgetType
from pylamarzocco.exceptions import RequestNotSuccessful
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
@@ -197,3 +197,25 @@ async def test_switch_exceptions(
blocking=True,
)
assert exc_info.value.translation_key == "auto_on_off_error"
async def test_switches_unavailable_if_machine_off(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the La Marzocco switches are unavailable when the device is offline."""
mock_lamarzocco.dashboard.config[
WidgetType.CM_MACHINE_STATUS
].status = MachineState.OFF
with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SWITCH]):
await async_init_integration(hass, mock_config_entry)
switches = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for switch in switches:
state = hass.states.get(switch.entity_id)
assert state
assert state.state == STATE_UNAVAILABLE
@@ -28,7 +28,7 @@
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <VacuumEntityFeature: 12308>,
'supported_features': <VacuumEntityFeature: 12316>,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1',
'unit_of_measurement': None,
@@ -38,7 +38,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Vacuum',
'supported_features': <VacuumEntityFeature: 12308>,
'supported_features': <VacuumEntityFeature: 12316>,
}),
'context': <ANY>,
'entity_id': 'vacuum.mock_vacuum',

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