Skip to content

Commit 0e06683

Browse files
committed
v3.5b1: Volume timing fixes, TTS cache improvements, diagnostics
## Bug Fixes ### Volume Restoration Timing - Fixed volume being restored too early when using chime or normalize features - Volume restoration now correctly waits for full playback duration - Improved detection of non-streaming audio mode (chime/normalize enabled) - Added platform-specific timing buffers (Sonos: 500ms, Cast: 700ms) ### Chime Configuration - Fixed chime settings not being applied from entity configuration - Service now reads chime/normalize settings from TTS entity subentry config - Settings from service call properly override entity defaults ### Voice Change Bug - Added `default_options` property to TTS entity - Voice/model/speed changes in config now properly invalidate HA's TTS cache - HA's cache key now includes current voice configuration ## New Features ### Duration Metadata with Mutagen - Added reliable MP3 metadata embedding using mutagen library - Duration stored in ID3 TXXX:duration_ms tag - Works across streaming and non-streaming modes - Survives HA's TTS cache operations ### In-Memory Duration Cache - Added MESSAGE_DURATIONS_KEY for fast duration lookups - Shared cache across all TTS entities - Reduces ffprobe calls for repeated messages ### Diagnostics Support - New diagnostics.py for troubleshooting - Exports config entry data (API key redacted) - Shows subentry configurations - Lists TTS entity states ## Code Quality - Fixed deprecated `asyncio.get_event_loop()` calls - Replaced with `asyncio.get_running_loop()` - Fixed bare `except:` clauses - Removed duplicate CONF_PROFILE_NAME definitions - Added ConfigEntryAuthFailed and ConfigEntryNotReady imports - Added type annotations to config flow classes
1 parent baa7d21 commit 0e06683

File tree

10 files changed

+1100
-209
lines changed

10 files changed

+1100
-209
lines changed

custom_components/openai_tts/__init__.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from homeassistant.const import Platform, ATTR_ENTITY_ID
1313
from homeassistant.core import HomeAssistant, ServiceCall
1414
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
15+
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
1516
from homeassistant.helpers import config_validation as cv, device_registry as dr, entity_registry as er
1617
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
1718

@@ -41,7 +42,6 @@
4142
PLATFORMS: list[str] = [Platform.TTS]
4243
SERVICE_NAME = "say"
4344
SUBENTRY_TYPE_PROFILE = "profile"
44-
CONF_PROFILE_NAME = "profile_name"
4545

4646
# Service Schema
4747
SAY_SCHEMA = vol.Schema(
@@ -445,21 +445,58 @@ async def _handle_say(call: ServiceCall) -> None:
445445

446446
# Validate TTS entity
447447
tts_entity = data["tts_entity"]
448-
if not hass.states.get(tts_entity):
448+
tts_state = hass.states.get(tts_entity)
449+
if not tts_state:
449450
raise ValueError(f"TTS entity {tts_entity} not found")
450-
451-
# Get service data (excluding entity_id)
451+
452+
# Get TTS entity's default options from its config
453+
# Look up the entity to get its default_options property
454+
entity_defaults = {}
455+
entity_reg = er.async_get(hass)
456+
entity_entry = entity_reg.async_get(tts_entity)
457+
if entity_entry and entity_entry.config_subentry_id:
458+
# This is a subentry-based entity - find the parent and subentry
459+
for entry in hass.config_entries.async_entries(DOMAIN):
460+
if hasattr(entry, 'subentries') and entry.subentries:
461+
for subentry_id, subentry in entry.subentries.items():
462+
if subentry_id == entity_entry.config_subentry_id:
463+
entity_defaults = {
464+
"chime": subentry.data.get(CONF_CHIME_ENABLE, False),
465+
"chime_sound": subentry.data.get(CONF_CHIME_SOUND, "threetone.mp3"),
466+
"normalize_audio": subentry.data.get(CONF_NORMALIZE_AUDIO, False),
467+
}
468+
_LOGGER.debug("Found entity defaults from subentry: %s", entity_defaults)
469+
break
470+
elif entity_entry and entity_entry.config_entry_id:
471+
# Legacy entry - get from config entry options
472+
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
473+
if config_entry:
474+
entity_defaults = {
475+
"chime": config_entry.options.get(CONF_CHIME_ENABLE, config_entry.data.get(CONF_CHIME_ENABLE, False)),
476+
"chime_sound": config_entry.options.get(CONF_CHIME_SOUND, config_entry.data.get(CONF_CHIME_SOUND, "threetone.mp3")),
477+
"normalize_audio": config_entry.options.get(CONF_NORMALIZE_AUDIO, config_entry.data.get(CONF_NORMALIZE_AUDIO, False)),
478+
}
479+
_LOGGER.debug("Found entity defaults from config entry: %s", entity_defaults)
480+
481+
# Get service data - use entity defaults for options not explicitly set
452482
message = data["message"]
453483
language = data.get("language", "en")
484+
485+
# For chime/normalize_audio: use service call value if provided, else entity default
486+
# Note: data.get("chime") returns None if not in call, False if explicitly set to False
487+
chime_value = data.get("chime") if "chime" in data else entity_defaults.get("chime", False)
488+
normalize_value = data.get("normalize_audio") if "normalize_audio" in data else entity_defaults.get("normalize_audio", False)
489+
chime_sound_value = data.get("chime_sound") if "chime_sound" in data else entity_defaults.get("chime_sound")
490+
454491
options = {
455492
"voice": data.get("voice"),
456493
"speed": data.get("speed"),
457494
"instructions": data.get("instructions"),
458-
"chime": data.get("chime", False),
459-
"chime_sound": data.get("chime_sound"),
460-
"normalize_audio": data.get("normalize_audio", False),
495+
"chime": chime_value,
496+
"chime_sound": chime_sound_value,
497+
"normalize_audio": normalize_value,
461498
}
462-
499+
463500
# Remove None values
464501
options = {k: v for k, v in options.items() if v is not None}
465502

custom_components/openai_tts/config_flow.py

Lines changed: 131 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import logging
1010
from urllib.parse import urlparse
1111
import uuid
12+
import aiohttp
1213

1314
from homeassistant import data_entry_flow
1415
from homeassistant.config_entries import (
@@ -20,7 +21,7 @@
2021
SubentryFlowResult,
2122
)
2223
from homeassistant.helpers.selector import selector, TextSelector, TextSelectorConfig, TextSelectorType, TemplateSelector
23-
from homeassistant.exceptions import HomeAssistantError
24+
from homeassistant.exceptions import HomeAssistantError, ConfigEntryAuthFailed
2425
from homeassistant.core import callback
2526

2627
from .const import (
@@ -39,17 +40,82 @@
3940
CONF_INSTRUCTIONS,
4041
CONF_VOLUME_RESTORE,
4142
CONF_PAUSE_PLAYBACK,
43+
CONF_PROFILE_NAME,
4244
)
4345

44-
CONF_PROFILE_NAME = "profile_name"
4546
SUBENTRY_TYPE_PROFILE = "profile"
4647

4748
_LOGGER = logging.getLogger(__name__)
4849

50+
# Custom exceptions for API validation
51+
class InvalidAPIKey(HomeAssistantError):
52+
"""Error to indicate invalid API key."""
53+
54+
class CannotConnect(HomeAssistantError):
55+
"""Error to indicate connection failure."""
56+
4957
def generate_entry_id() -> str:
5058
return str(uuid.uuid4())
5159

52-
async def validate_user_input(user_input: dict):
60+
async def async_validate_api_key(api_key: str, url: str) -> bool:
61+
"""Validate the API key by making a minimal test request.
62+
63+
Args:
64+
api_key: The OpenAI API key to validate
65+
url: The API endpoint URL
66+
67+
Returns:
68+
True if validation succeeds
69+
70+
Raises:
71+
InvalidAPIKey: If the API key is invalid (401/403)
72+
CannotConnect: If unable to connect to the API
73+
"""
74+
headers = {
75+
"Content-Type": "application/json",
76+
"Authorization": f"Bearer {api_key}"
77+
}
78+
79+
# Make a minimal TTS request to validate the API key
80+
# Using minimal text to reduce cost
81+
payload = {
82+
"model": "tts-1",
83+
"input": ".",
84+
"voice": "alloy",
85+
"response_format": "mp3",
86+
}
87+
88+
try:
89+
async with aiohttp.ClientSession() as session:
90+
async with session.post(
91+
url,
92+
json=payload,
93+
headers=headers,
94+
timeout=aiohttp.ClientTimeout(total=10)
95+
) as response:
96+
if response.status == 401:
97+
_LOGGER.error("API key validation failed: Unauthorized (401)")
98+
raise InvalidAPIKey("Invalid API key")
99+
elif response.status == 403:
100+
_LOGGER.error("API key validation failed: Forbidden (403)")
101+
raise InvalidAPIKey("API key does not have required permissions")
102+
elif response.status >= 400:
103+
_LOGGER.error("API validation failed with status %d", response.status)
104+
raise CannotConnect(f"API returned status {response.status}")
105+
106+
# Success - we got audio data back
107+
_LOGGER.debug("API key validation successful")
108+
return True
109+
110+
except aiohttp.ClientError as err:
111+
_LOGGER.error("Connection error during API validation: %s", err)
112+
raise CannotConnect(f"Cannot connect to API: {err}") from err
113+
except TimeoutError as err:
114+
_LOGGER.error("Timeout during API validation")
115+
raise CannotConnect("Connection timed out") from err
116+
117+
async def validate_user_input(user_input: dict) -> None:
118+
"""Validate user input for config flow."""
53119
if user_input.get(CONF_API_KEY) is None:
54120
raise ValueError("API key is required")
55121

@@ -89,9 +155,11 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con
89155
if user_input is not None:
90156
try:
91157
await validate_user_input(user_input)
92-
158+
93159
# Check for duplicate API key
94160
api_key = user_input.get(CONF_API_KEY)
161+
api_url = user_input.get(CONF_URL, "https://api.openai.com/v1/audio/speech")
162+
95163
for entry in self._async_current_entries():
96164
if entry.data.get(CONF_API_KEY) == api_key:
97165
_LOGGER.error("An entry with this API key already exists: %s", entry.title)
@@ -102,7 +170,10 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con
102170
data_schema=self.data_schema,
103171
errors=errors,
104172
)
105-
173+
174+
# Validate API key by making a test request
175+
await async_validate_api_key(api_key, api_url)
176+
106177
# Use API key as the unique identifier (hashed for privacy)
107178
import hashlib
108179
api_key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]
@@ -116,6 +187,10 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con
116187
)
117188
except data_entry_flow.AbortFlow:
118189
return self.async_abort(reason="already_configured")
190+
except InvalidAPIKey:
191+
errors["base"] = "invalid_api_key"
192+
except CannotConnect:
193+
errors["base"] = "cannot_connect"
119194
except HomeAssistantError as e:
120195
_LOGGER.exception(str(e))
121196
errors["base"] = str(e)
@@ -171,7 +246,53 @@ def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool:
171246
is_legacy = has_model_voice and not has_subentries
172247

173248
return is_legacy
174-
249+
250+
async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult:
251+
"""Handle reauthorization flow triggered by auth failure."""
252+
self._reauth_entry = self.hass.config_entries.async_get_entry(
253+
self.context.get("entry_id")
254+
)
255+
return await self.async_step_reauth_confirm()
256+
257+
async def async_step_reauth_confirm(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
258+
"""Handle reauthorization confirmation."""
259+
errors: dict[str, str] = {}
260+
261+
if user_input is not None:
262+
try:
263+
api_key = user_input.get(CONF_API_KEY)
264+
api_url = self._reauth_entry.data.get(CONF_URL, "https://api.openai.com/v1/audio/speech")
265+
266+
# Validate the new API key
267+
await async_validate_api_key(api_key, api_url)
268+
269+
# Update the entry with new credentials
270+
self.hass.config_entries.async_update_entry(
271+
self._reauth_entry,
272+
data={**self._reauth_entry.data, CONF_API_KEY: api_key},
273+
)
274+
await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
275+
return self.async_abort(reason="reauth_successful")
276+
277+
except InvalidAPIKey:
278+
errors["base"] = "invalid_api_key"
279+
except CannotConnect:
280+
errors["base"] = "cannot_connect"
281+
except Exception:
282+
_LOGGER.exception("Unexpected error during reauth")
283+
errors["base"] = "unknown_error"
284+
285+
return self.async_show_form(
286+
step_id="reauth_confirm",
287+
data_schema=vol.Schema({
288+
vol.Required(CONF_API_KEY): str,
289+
}),
290+
errors=errors,
291+
description_placeholders={
292+
"title": self._reauth_entry.title if self._reauth_entry else "OpenAI TTS"
293+
},
294+
)
295+
175296
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
176297
"""Handle reconfiguration of the parent entry."""
177298
errors: dict[str, str] = {}
@@ -456,12 +577,12 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None)
456577

457578
class OpenAITTSOptionsFlow(OptionsFlow):
458579
"""Handle options flow for OpenAI TTS."""
459-
460-
def __init__(self, config_entry):
580+
581+
def __init__(self, config_entry: ConfigEntry) -> None:
461582
"""Initialize options flow."""
462583
self._config_entry = config_entry
463-
464-
async def async_step_init(self, user_input: dict | None = None):
584+
585+
async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult:
465586
# Check if this is a profile (subentry) or main entry
466587
is_profile = hasattr(self._config_entry, 'subentry_type') and self._config_entry.subentry_type == SUBENTRY_TYPE_PROFILE
467588

custom_components/openai_tts/const.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,7 @@
2626
CONF_PAUSE_PLAYBACK = "pause_playback"
2727

2828
# Profile name for sub-entries
29-
CONF_PROFILE_NAME = "profile_name"
29+
CONF_PROFILE_NAME = "profile_name"
30+
31+
# Key for storing message-to-duration cache in hass.data
32+
MESSAGE_DURATIONS_KEY = "message_durations"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Diagnostics support for OpenAI TTS."""
2+
from __future__ import annotations
3+
4+
from typing import Any
5+
6+
from homeassistant.components.diagnostics import async_redact_data
7+
from homeassistant.config_entries import ConfigEntry
8+
from homeassistant.core import HomeAssistant
9+
10+
from .const import CONF_API_KEY, DOMAIN
11+
12+
# Keys to redact from diagnostics
13+
TO_REDACT = {CONF_API_KEY}
14+
15+
16+
async def async_get_config_entry_diagnostics(
17+
hass: HomeAssistant, entry: ConfigEntry
18+
) -> dict[str, Any]:
19+
"""Return diagnostics for a config entry."""
20+
# Redact sensitive data from entry
21+
data = {
22+
"entry": {
23+
"entry_id": entry.entry_id,
24+
"version": f"{entry.version}.{entry.minor_version}",
25+
"domain": entry.domain,
26+
"title": entry.title,
27+
"data": async_redact_data(dict(entry.data), TO_REDACT),
28+
"options": async_redact_data(dict(entry.options), TO_REDACT),
29+
},
30+
}
31+
32+
# Add subentries info if present
33+
if hasattr(entry, 'subentries') and entry.subentries:
34+
data["subentries"] = []
35+
for subentry_id, subentry in entry.subentries.items():
36+
subentry_info = {
37+
"subentry_id": subentry_id,
38+
"title": subentry.title,
39+
"subentry_type": getattr(subentry, 'subentry_type', None),
40+
"data": async_redact_data(dict(subentry.data), TO_REDACT),
41+
}
42+
data["subentries"].append(subentry_info)
43+
44+
# Add TTS entity states
45+
tts_entities = []
46+
for state in hass.states.async_all("tts"):
47+
if state.entity_id.startswith("tts.openai_tts"):
48+
tts_entities.append({
49+
"entity_id": state.entity_id,
50+
"state": state.state,
51+
"attributes": {
52+
k: v for k, v in state.attributes.items()
53+
if k not in TO_REDACT
54+
},
55+
})
56+
57+
data["tts_entities"] = tts_entities
58+
59+
# Add integration domain data (without sensitive info)
60+
domain_data = hass.data.get(DOMAIN, {})
61+
data["domain_data"] = {
62+
"entry_count": len([k for k in domain_data.keys() if not k.startswith("_")]),
63+
"has_main_entry": "main_entry" in domain_data,
64+
}
65+
66+
return data

custom_components/openai_tts/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
"documentation": "https://github.com/sfortis/openai_tts/",
1010
"iot_class": "cloud_polling",
1111
"issue_tracker": "https://github.com/sfortis/openai_tts/issues",
12-
"requirements": [],
13-
"version": "3.4b5"
12+
"requirements": ["aiohttp>=3.9.0"],
13+
"version": "3.5b1"
1414
}

0 commit comments

Comments
 (0)