fix: Comprehensive v2.4.0 security, stability, and quality improvements

Security:
- Add SSRF protection via HTTPS-only endpoint validation with private IP blocking
- Mask API keys in all log output via safe_log_data()
- Sanitize error responses to prevent internal detail leakage
- Remove API key pre-population in config flow forms
- Harden service schemas with input length limits and type coercion

Bug fixes:
- Fix temperature=0 rejected due to Python falsy value bug (0 or default)
- Fix fire-and-forget race condition in coordinator init (5 async_create_task → awaited async_initialize)
- Fix rate limit state not resetting after successful API calls
- Fix ConnectionError incorrectly setting is_rate_limited=True
- Fix _rotate_history calling async method via sync executor
- Fix shutil.move blocking event loop in history migration
- Fix async_shutdown removing wrong key from hass.data
- Replace os.rename with shutil.move for cross-device safety

Improvements:
- Add asyncio.Lock for request serialization
- Replace async_timeout with stdlib asyncio.timeout (Python 3.12+)
- Use SupportsResponse.OPTIONAL for ask_question and get_history services
- Use Platform.SENSOR enum instead of string literal
- Add strings.json for HA translation framework
- Update DEFAULT_ANTHROPIC_MODEL to claude-sonnet-4-6
- Retry only transient errors (429, timeout) in API client

Cleanup:
- Remove dead code: unused schemas, imports, sync_write_history, check_memory, check_connection
- Remove icon copying code from async_setup
- Remove unused dependencies from manifest (anthropic, openai, certifi, async-timeout)
- Remove empty manifest arrays (bluetooth, mqtt, ssdp, usb, zeroconf)
- Fix duplicate JSON keys in 5 translation files
- Fix Serbian translation: "Прекључено" → "Искључено" for disconnected state

Bump version to 2.4.0
This commit is contained in:
SMKRV
2026-03-12 01:24:01 +03:00
parent ce0a75f219
commit 998103ce73
15 changed files with 564 additions and 460 deletions
+34 -73
View File
@@ -9,24 +9,23 @@ The HA Text AI integration.
from __future__ import annotations
import logging
import os
import shutil
from datetime import datetime, timedelta
from typing import Any, Dict, TypeVar
from datetime import datetime
from typing import Any, Dict
import asyncio
import voluptuous as vol
from async_timeout import timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import aiohttp_client
from .coordinator import HATextAICoordinator
from .api_client import APIClient
from .utils import get_file_hash, safe_log_data
from .utils import safe_log_data, validate_endpoint
from .providers import get_default_endpoint, get_default_model, build_auth_headers
from .const import (
DOMAIN,
@@ -53,24 +52,24 @@ from .const import (
SERVICE_SET_SYSTEM_PROMPT,
DEFAULT_MAX_HISTORY,
CONF_MAX_HISTORY_SIZE,
ICONS_SUBDOMAIN,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
ConfigType = TypeVar("ConfigType", bound=Dict[str, Any])
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
vol.Required("instance"): cv.string,
vol.Required("question"): cv.string,
vol.Optional("system_prompt"): cv.string,
vol.Required("question"): vol.All(cv.string, vol.Length(min=1, max=100000)),
vol.Optional("system_prompt"): vol.All(cv.string, vol.Length(max=50000)),
vol.Optional("model"): cv.string,
vol.Optional("temperature"): cv.positive_float,
vol.Optional("temperature"): vol.All(
vol.Coerce(float), vol.Range(min=0.0, max=2.0)
),
vol.Optional("max_tokens"): cv.positive_int,
vol.Optional("context_messages"): cv.positive_int,
vol.Optional("structured_output", default=False): cv.boolean,
vol.Optional("json_schema"): cv.string,
vol.Optional("json_schema"): vol.All(cv.string, vol.Length(max=50000)),
})
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
@@ -98,7 +97,7 @@ def get_coordinator_by_instance(hass: HomeAssistant, instance: str) -> HATextAIC
raise HomeAssistantError(f"Instance {instance} not found")
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
"""Set up the Home Assistant Text AI component."""
# Initialize domain data storage
hass.data.setdefault(DOMAIN, {})
@@ -143,7 +142,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"question": call.data["question"],
"timestamp": datetime.now().isoformat(),
"success": False,
"error": str(err)
"error": "Service call failed"
}
async def async_clear_history(call: ServiceCall) -> None:
@@ -185,7 +184,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
SERVICE_ASK_QUESTION,
async_ask_question,
schema=SERVICE_SCHEMA_ASK_QUESTION,
supports_response=True
supports_response=SupportsResponse.OPTIONAL
)
hass.services.async_register(
@@ -199,7 +198,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
DOMAIN,
SERVICE_GET_HISTORY,
async_get_history,
schema=SERVICE_SCHEMA_GET_HISTORY
schema=SERVICE_SCHEMA_GET_HISTORY,
supports_response=SupportsResponse.OPTIONAL
)
hass.services.async_register(
@@ -209,53 +209,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
)
# Handle icons
try:
source_icon_path = os.path.join(
os.path.dirname(__file__),
ICONS_SUBDOMAIN,
'icon@2x.png'
)
destination_directory = os.path.join(
hass.config.path('www'),
DOMAIN,
ICONS_SUBDOMAIN
)
destination_icon_path = os.path.join(
destination_directory,
'icon.png'
)
if not os.path.exists(source_icon_path):
_LOGGER.error("Source icon not found: %s", source_icon_path)
return True
def create_directory():
os.makedirs(destination_directory, exist_ok=True)
await hass.async_add_executor_job(create_directory)
should_copy = True
if os.path.exists(destination_icon_path):
source_hash = await hass.async_add_executor_job(get_file_hash, source_icon_path)
dest_hash = await hass.async_add_executor_job(get_file_hash, destination_icon_path)
should_copy = source_hash != dest_hash
if should_copy:
def copy_file():
shutil.copyfile(source_icon_path, destination_icon_path)
await hass.async_add_executor_job(copy_file)
_LOGGER.debug("Icon updated: %s", destination_icon_path)
except PermissionError as e:
_LOGGER.error("Permission denied when managing icons: %s", str(e))
except Exception as e:
_LOGGER.error("Failed to manage icons: %s", str(e))
return True
async def async_check_api(session, endpoint: str, headers: dict, provider: str, api_timeout: int = DEFAULT_API_TIMEOUT) -> bool:
@@ -275,9 +228,9 @@ async def async_check_api(session, endpoint: str, headers: dict, provider: str,
else: # OpenAI
check_url = f"{endpoint}/models"
async with timeout(api_timeout):
async with asyncio.timeout(api_timeout):
async with session.get(check_url, headers=headers) as response:
if response.status in [200, 404]:
if response.status == 200:
return True
elif response.status == 401:
_LOGGER.error("Invalid API key")
@@ -294,7 +247,7 @@ async def async_check_api(session, endpoint: str, headers: dict, provider: str,
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HA Text AI from a config entry."""
_LOGGER.debug(f"Setting up HA Text AI entry: {entry.data}")
_LOGGER.debug("Setting up HA Text AI entry: %s", safe_log_data(dict(entry.data)))
try:
# Get provider from data or options (options takes precedence)
@@ -308,7 +261,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
session = aiohttp_client.async_get_clientsession(hass)
model = config.get(CONF_MODEL, get_default_model(api_provider))
endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(api_provider)).rstrip('/')
raw_endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(api_provider))
try:
endpoint = validate_endpoint(raw_endpoint)
except ValueError as err:
_LOGGER.error("Invalid API endpoint %s: %s", raw_endpoint, err)
raise ConfigEntryNotReady(f"Invalid API endpoint: {err}")
# API key can now be updated via options
api_key = config.get(CONF_API_KEY, entry.data.get(CONF_API_KEY))
instance_name = entry.data.get(CONF_NAME, entry.entry_id)
@@ -350,13 +308,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api_timeout=api_timeout,
)
_LOGGER.debug(f"Created coordinator for {instance_name}")
# Initialize coordinator (directories, history, metrics)
await coordinator.async_initialize()
_LOGGER.debug("Created coordinator for %s", instance_name)
# Store coordinator
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
_LOGGER.debug(f"Stored coordinator in hass.data[{DOMAIN}][{entry.entry_id}]")
_LOGGER.debug("Stored coordinator in hass.data[%s][%s]", DOMAIN, entry.entry_id)
# Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -364,12 +325,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Register update listener for options changes
entry.async_on_unload(entry.add_update_listener(async_update_options))
_LOGGER.debug(f"Setup completed for {instance_name}")
_LOGGER.debug("Setup completed for %s", instance_name)
return True
except Exception as err:
_LOGGER.exception(f"Error setting up HA Text AI: {err}")
_LOGGER.exception("Error setting up HA Text AI: %s", err)
raise
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: