Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16c6bd08f8 | |||
| 18834849c2 | |||
| e4d820799f | |||
| 013a35176a | |||
| 8230557aef | |||
| 5451063714 | |||
| 8cdc7523a4 | |||
| 77ccfbd3a9 | |||
| 4977ee4998 | |||
| 5c0f2d37f0 | |||
| 0b5d2ab8e4 | |||
| 47f3bf29dd | |||
| 62f7cbb51e | |||
| b9e2c5d34c | |||
| 1829acd0e1 | |||
| 41b9a7a9a3 | |||
| 9782637ec8 | |||
| 6bd6fa65d2 | |||
| 85343a9f53 | |||
| bc607dd013 | |||
| c2c388e0cc | |||
| 3fc154e1d7 | |||
| efb29d024e | |||
| 263823c92c | |||
| e5e6ed601b | |||
| 28dfc997f3 | |||
| f93ab8d519 | |||
| cb359da79e | |||
| 6a7385590a | |||
| c0ec987b07 | |||
| 26521f8cc0 | |||
| 4df1f702bf | |||
| c8422c9fb8 | |||
| f8207a2e0e | |||
| 9cc75f3458 | |||
| a233b6b1e3 | |||
| c7677b91da | |||
| 1f57bba9cd | |||
| 4cc10ca2e2 | |||
| 153e1e43e8 | |||
| 398dd3ae46 | |||
| 17fd850fa6 | |||
| ae062b230c | |||
| d523f85404 | |||
| f28d6582c6 | |||
| 1e81e5990e | |||
| 5fe2e4b6ed | |||
| 914bb3aa76 | |||
| cfa6746115 | |||
| 03f9caf3eb | |||
| 6b2aaf3fdb | |||
| 2c4ea0d584 | |||
| e627811f7a | |||
| 150f41641b | |||
| b9a7371996 | |||
| 7d0e99da43 | |||
| 71f281cc14 | |||
| aec812a475 | |||
| d4b548b169 | |||
| a296324c30 | |||
| cff3d3d6ac |
Generated
+2
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyW215"],
|
||||
"requirements": ["pyW215==0.7.0"]
|
||||
"requirements": ["pyW215==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
Generated
+7
-10
@@ -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
|
||||
|
||||
Generated
+7
-7
@@ -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}
|
||||
@@ -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()
|
||||
|
||||
+29
-11
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user