mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-21 22:54:00 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -10,10 +10,7 @@ import logging
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from async_timeout import timeout
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from .const import (
|
||||
DEFAULT_API_TIMEOUT,
|
||||
@@ -88,38 +85,58 @@ class APIClient:
|
||||
url: str,
|
||||
payload: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Make API request with retry logic."""
|
||||
# Log request without sensitive data
|
||||
"""Make API request with retry logic for transient errors only."""
|
||||
safe_payload = {k: v for k, v in payload.items() if k not in ['messages', 'system']}
|
||||
_LOGGER.debug(f"API Request: URL={url}, Safe payload: {safe_payload}")
|
||||
_LOGGER.debug("API Request: URL=%s, Safe payload: %s", url, safe_payload)
|
||||
|
||||
for attempt in range(API_RETRY_COUNT):
|
||||
try:
|
||||
async with timeout(self.api_timeout):
|
||||
async with self.session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
) as response:
|
||||
_LOGGER.debug(f"Response status: {response.status}")
|
||||
if response.status != 200:
|
||||
error_data = await response.json()
|
||||
# Log error without sensitive data
|
||||
safe_error = {k: v for k, v in error_data.items() if k not in ['message', 'details']}
|
||||
_LOGGER.error(f"API error (status {response.status}): {safe_error}")
|
||||
raise HomeAssistantError(f"API error: status {response.status}")
|
||||
async with self.session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
) as response:
|
||||
_LOGGER.debug("Response status: %s", response.status)
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
|
||||
# Try to get error details
|
||||
error_data = {}
|
||||
try:
|
||||
error_data = await response.json()
|
||||
except Exception:
|
||||
error_data = {"raw": await response.text()}
|
||||
|
||||
# Rate limit — retry with backoff
|
||||
if response.status == 429:
|
||||
_LOGGER.warning(
|
||||
"Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT
|
||||
)
|
||||
if attempt < API_RETRY_COUNT - 1:
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
continue
|
||||
raise HomeAssistantError("API rate limit exceeded")
|
||||
|
||||
# Client/server errors — don't retry
|
||||
_LOGGER.error("API error (status %d): %s", response.status, error_data)
|
||||
raise HomeAssistantError(f"API error: status {response.status}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.warning(f"Timeout on attempt {attempt + 1}/{API_RETRY_COUNT}")
|
||||
_LOGGER.warning("Timeout on attempt %d/%d", attempt + 1, API_RETRY_COUNT)
|
||||
if attempt == API_RETRY_COUNT - 1:
|
||||
raise HomeAssistantError("API request timed out")
|
||||
await asyncio.sleep(1 * (attempt + 1))
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
except HomeAssistantError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_LOGGER.warning(f"API request failed on attempt {attempt + 1}/{API_RETRY_COUNT}: {type(e).__name__}")
|
||||
_LOGGER.warning(
|
||||
"API request failed on attempt %d/%d: %s",
|
||||
attempt + 1, API_RETRY_COUNT, type(e).__name__,
|
||||
)
|
||||
if attempt == API_RETRY_COUNT - 1:
|
||||
raise
|
||||
await asyncio.sleep(1 * (attempt + 1))
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
@@ -320,15 +337,6 @@ class APIClient:
|
||||
},
|
||||
}
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
"""Check API connection."""
|
||||
try:
|
||||
await self._make_request(self.endpoint, {"test": "connection"})
|
||||
return True
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Connection check failed: {str(e)}")
|
||||
return False
|
||||
|
||||
async def _create_gemini_completion(
|
||||
self,
|
||||
model: str,
|
||||
|
||||
@@ -49,7 +49,7 @@ from .const import (
|
||||
DEFAULT_MAX_HISTORY,
|
||||
CONF_MAX_HISTORY_SIZE,
|
||||
)
|
||||
from .utils import normalize_name # noqa: F401 — re-exported for backward compat
|
||||
from .utils import normalize_name, safe_log_data, validate_endpoint # noqa: F401 — re-exported for backward compat
|
||||
from .providers import get_default_endpoint, get_default_model
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -132,16 +132,15 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
})
|
||||
)
|
||||
|
||||
# Debug log to identify what's in the input
|
||||
_LOGGER.debug(f"Provider step input data: {user_input}")
|
||||
_LOGGER.debug("Provider step input data: %s", safe_log_data(user_input))
|
||||
|
||||
input_copy = user_input.copy()
|
||||
|
||||
# Check if CONF_NAME exists in input_copy and ensure it's not empty
|
||||
if CONF_NAME not in input_copy or not input_copy[CONF_NAME]:
|
||||
_LOGGER.warning(f"Missing name in configuration input: {input_copy}")
|
||||
input_copy[CONF_NAME] = f"gemini_assistant_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
_LOGGER.info(f"Auto-generated name: {input_copy[CONF_NAME]}")
|
||||
_LOGGER.warning("Missing name in configuration input: %s", safe_log_data(input_copy))
|
||||
input_copy[CONF_NAME] = f"assistant_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
_LOGGER.info("Auto-generated name: %s", input_copy[CONF_NAME])
|
||||
|
||||
# Ensure API key is present
|
||||
if CONF_API_KEY not in input_copy or not input_copy[CONF_API_KEY]:
|
||||
@@ -197,7 +196,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="provider",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str,
|
||||
vol.Required(CONF_API_KEY, default=input_copy.get(CONF_API_KEY, "")): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, get_default_model(self._provider))): str,
|
||||
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
|
||||
vol.Optional(CONF_TEMPERATURE, default=input_copy.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All(
|
||||
@@ -259,7 +258,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="provider",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str,
|
||||
vol.Required(CONF_API_KEY, default=input_copy.get(CONF_API_KEY, "")): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, get_default_model(self._provider))): str,
|
||||
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
|
||||
vol.Optional(CONF_TEMPERATURE, default=input_copy.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All(
|
||||
@@ -302,7 +301,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="provider",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str,
|
||||
vol.Required(CONF_API_KEY, default=input_copy.get(CONF_API_KEY, "")): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, get_default_model(self._provider))): str,
|
||||
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
|
||||
# Other fields remain the same
|
||||
@@ -353,9 +352,16 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._errors["base"] = "invalid_auth"
|
||||
return False
|
||||
|
||||
# Validate endpoint URL (HTTPS-only, SSRF protection)
|
||||
try:
|
||||
endpoint = validate_endpoint(user_input[CONF_API_ENDPOINT])
|
||||
except ValueError as err:
|
||||
_LOGGER.error("Endpoint validation failed: %s", err)
|
||||
self._errors["base"] = "cannot_connect"
|
||||
return False
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
headers = self._get_api_headers(user_input)
|
||||
endpoint = user_input[CONF_API_ENDPOINT].rstrip('/')
|
||||
|
||||
if self._provider == API_PROVIDER_GEMINI:
|
||||
if not user_input[CONF_API_KEY]:
|
||||
@@ -434,7 +440,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if key not in entry_data:
|
||||
entry_data[key] = value
|
||||
|
||||
_LOGGER.debug(f"Creating config entry with data: {entry_data}")
|
||||
_LOGGER.debug("Creating config entry with data: %s", safe_log_data(entry_data))
|
||||
|
||||
return self.async_create_entry(
|
||||
title=instance_name,
|
||||
@@ -484,13 +490,20 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
self._errors["base"] = "invalid_auth"
|
||||
return False
|
||||
|
||||
# Validate endpoint URL (HTTPS-only, SSRF protection)
|
||||
try:
|
||||
endpoint = validate_endpoint(endpoint)
|
||||
except ValueError as err:
|
||||
_LOGGER.error("Endpoint validation failed: %s", err)
|
||||
self._errors["base"] = "cannot_connect"
|
||||
return False
|
||||
|
||||
# For Gemini, just check if API key is present
|
||||
if provider == API_PROVIDER_GEMINI:
|
||||
return True
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
headers = self._get_api_headers(api_key, provider)
|
||||
endpoint = endpoint.rstrip('/')
|
||||
|
||||
check_url = (
|
||||
f"{endpoint}/v1/models" if provider == API_PROVIDER_ANTHROPIC
|
||||
@@ -607,10 +620,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
data = user_input or current_data
|
||||
|
||||
return vol.Schema({
|
||||
vol.Required(
|
||||
CONF_API_KEY,
|
||||
default=data.get(CONF_API_KEY, "")
|
||||
): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(
|
||||
CONF_API_ENDPOINT,
|
||||
default=data.get(CONF_API_ENDPOINT, default_endpoint)
|
||||
|
||||
@@ -9,15 +9,13 @@ Constants for the HA text AI integration.
|
||||
import os
|
||||
import json
|
||||
from typing import Final
|
||||
import voluptuous as vol
|
||||
from homeassistant.const import Platform, CONF_API_KEY, CONF_NAME
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Domain and platforms
|
||||
DOMAIN: Final = "ha_text_ai"
|
||||
PLATFORMS: list[str] = ["sensor"]
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
# Provider configuration
|
||||
CONF_API_PROVIDER: Final = "api_provider"
|
||||
@@ -72,11 +70,9 @@ CONF_JSON_SCHEMA: Final = "json_schema"
|
||||
ABSOLUTE_MAX_HISTORY_SIZE = 500
|
||||
MAX_ATTRIBUTE_SIZE = 4 * 1024
|
||||
MAX_HISTORY_FILE_SIZE = 1 * 1024 * 1024
|
||||
ICONS_SUBDOMAIN = "icons"
|
||||
|
||||
# Default values
|
||||
DEFAULT_MODEL: Final = "gpt-4o-mini"
|
||||
DEFAULT_ANTHROPIC_MODEL: Final = "claude-3-5-sonnet"
|
||||
DEFAULT_ANTHROPIC_MODEL: Final = "claude-sonnet-4-6"
|
||||
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-chat"
|
||||
DEFAULT_GEMINI_MODEL: Final = "gemini-2.0-flash"
|
||||
DEFAULT_TEMPERATURE: Final = 0.1
|
||||
@@ -184,74 +180,3 @@ STATE_DISCONNECTED: Final = "disconnected"
|
||||
EVENT_RESPONSE_RECEIVED: Final = f"{DOMAIN}_response_received"
|
||||
EVENT_ERROR_OCCURRED: Final = f"{DOMAIN}_error_occurred"
|
||||
EVENT_STATE_CHANGED: Final = f"{DOMAIN}_state_changed"
|
||||
|
||||
# Service schema constants
|
||||
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
|
||||
vol.Required(CONF_INSTANCE): cv.string,
|
||||
vol.Required("question"): cv.string,
|
||||
vol.Optional("system_prompt"): cv.string,
|
||||
vol.Optional("model"): cv.string,
|
||||
vol.Optional("temperature"): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||
),
|
||||
vol.Optional("max_tokens"): vol.All(
|
||||
vol.Coerce(int),
|
||||
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
|
||||
),
|
||||
vol.Optional("context_messages"): vol.All(
|
||||
vol.Coerce(int),
|
||||
vol.Range(min=1, max=20)
|
||||
),
|
||||
vol.Optional(CONF_STRUCTURED_OUTPUT, default=False): cv.boolean,
|
||||
vol.Optional(CONF_JSON_SCHEMA): cv.string,
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||
vol.Required(CONF_INSTANCE): cv.string,
|
||||
vol.Required("prompt"): cv.string
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
|
||||
vol.Required(CONF_INSTANCE): cv.string,
|
||||
vol.Optional("limit", default=10): vol.All(
|
||||
vol.Coerce(int),
|
||||
vol.Range(min=1, max=100),
|
||||
),
|
||||
vol.Optional("filter_model"): cv.string
|
||||
})
|
||||
|
||||
# Configuration schema
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_API_KEY): cv.string,
|
||||
vol.Required(CONF_API_PROVIDER): vol.In(API_PROVIDERS),
|
||||
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.string,
|
||||
vol.Optional(CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||
),
|
||||
vol.Optional(CONF_MAX_TOKENS, default=DEFAULT_MAX_TOKENS): vol.All(
|
||||
vol.Coerce(int),
|
||||
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
|
||||
),
|
||||
vol.Optional(CONF_API_ENDPOINT): cv.string,
|
||||
vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=MIN_REQUEST_INTERVAL, max=MAX_REQUEST_INTERVAL)
|
||||
),
|
||||
vol.Optional(CONF_API_TIMEOUT, default=DEFAULT_API_TIMEOUT): vol.All(
|
||||
vol.Coerce(int),
|
||||
vol.Range(min=MIN_API_TIMEOUT, max=MAX_API_TIMEOUT)
|
||||
),
|
||||
vol.Optional(CONF_MAX_HISTORY_SIZE, default=DEFAULT_MAX_HISTORY): vol.All( # Correct usage
|
||||
vol.Coerce(int),
|
||||
vol.Range(min=1, max=100),
|
||||
),
|
||||
vol.Optional(CONF_CONTEXT_MESSAGES, default=DEFAULT_CONTEXT_MESSAGES): vol.All(
|
||||
vol.Coerce(int),
|
||||
vol.Range(min=1, max=20)
|
||||
)
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
@@ -14,8 +14,6 @@ import aiofiles
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import psutil
|
||||
import re
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
@@ -116,6 +114,9 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
self.is_anthropic = is_anthropic
|
||||
self.api_timeout = api_timeout
|
||||
|
||||
# Concurrency control
|
||||
self._request_lock = asyncio.Lock()
|
||||
|
||||
# Initialize essential attributes
|
||||
self._is_processing = False
|
||||
self._is_rate_limited = False
|
||||
@@ -170,14 +171,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
"ha_text_ai_history"
|
||||
)
|
||||
|
||||
# Initialize all async tasks in proper order
|
||||
self.hass.async_create_task(self._create_history_dir())
|
||||
self.hass.async_create_task(self._check_history_directory())
|
||||
self.hass.async_create_task(self._initialize_metrics())
|
||||
self.hass.async_create_task(self.async_initialize_history_file())
|
||||
self.hass.async_create_task(self._migrate_history_from_txt_to_json())
|
||||
|
||||
# History file path using instance name
|
||||
# History file path using instance name — must be set before async_initialize
|
||||
self._history_file = os.path.join(
|
||||
self._history_dir,
|
||||
f"{self.normalized_name}_history.json"
|
||||
@@ -188,7 +182,15 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
|
||||
self.context_messages = context_messages
|
||||
|
||||
_LOGGER.info(f"Initialized HA Text AI coordinator with instance: {instance_name}")
|
||||
_LOGGER.info("Initialized HA Text AI coordinator with instance: %s", instance_name)
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
"""Initialize coordinator: directories, history, metrics. Must be awaited."""
|
||||
await self._create_history_dir()
|
||||
await self._check_history_directory()
|
||||
await self._initialize_metrics()
|
||||
await self.async_initialize_history_file()
|
||||
await self._migrate_history_from_txt_to_json()
|
||||
|
||||
@property
|
||||
def last_response(self) -> Dict[str, Any]:
|
||||
@@ -357,7 +359,6 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
HomeAssistantError: {"is_ha_error": True},
|
||||
ConnectionError: {
|
||||
"is_connection_error": True,
|
||||
"is_rate_limited": True
|
||||
},
|
||||
TimeoutError: {"is_timeout": True},
|
||||
PermissionError: {"is_permission_denied": True},
|
||||
@@ -529,67 +530,14 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.error(f"Error checking file size for {file_path}: {e}")
|
||||
return 0
|
||||
|
||||
def _sync_write_history_entry(self, entry: dict) -> None:
|
||||
"""Synchronous method to write history entry with enhanced error handling."""
|
||||
try:
|
||||
history = []
|
||||
if os.path.exists(self._history_file):
|
||||
try:
|
||||
with open(self._history_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if content.strip():
|
||||
history = json.loads(content)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
_LOGGER.warning(f"Corrupted history file, creating new: {e}")
|
||||
# Backup corrupted file
|
||||
backup_path = f"{self._history_file}.corrupted.{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}"
|
||||
try:
|
||||
os.rename(self._history_file, backup_path)
|
||||
_LOGGER.info(f"Corrupted history backed up to: {backup_path}")
|
||||
except OSError:
|
||||
pass
|
||||
except PermissionError as e:
|
||||
_LOGGER.error(f"Permission denied reading history file: {e}")
|
||||
raise
|
||||
except OSError as e:
|
||||
_LOGGER.error(f"OS error reading history file: {e}")
|
||||
raise
|
||||
|
||||
history.append(entry)
|
||||
|
||||
if len(history) > self.max_history_size:
|
||||
history = history[-self.max_history_size:]
|
||||
|
||||
# Write with atomic operation
|
||||
temp_file = f"{self._history_file}.tmp"
|
||||
try:
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(history, f, indent=2, ensure_ascii=False)
|
||||
os.replace(temp_file, self._history_file)
|
||||
except PermissionError as e:
|
||||
_LOGGER.error(f"Permission denied writing history file: {e}")
|
||||
raise
|
||||
except OSError as e:
|
||||
_LOGGER.error(f"OS error writing history file: {e}")
|
||||
# Clean up temp file if it exists
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Synchronous history entry writing failed: {e}")
|
||||
raise
|
||||
|
||||
async def _rotate_history(self) -> None:
|
||||
"""Rotate conversation history with file management."""
|
||||
try:
|
||||
_LOGGER.debug(f"Starting history rotation for {self._history_file}")
|
||||
await self.hass.async_add_executor_job(self._rotate_history_files)
|
||||
_LOGGER.debug(f"Completed history rotation")
|
||||
_LOGGER.debug("Starting history rotation for %s", self._history_file)
|
||||
await self._rotate_history_files()
|
||||
_LOGGER.debug("Completed history rotation")
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error rotating history: {e}")
|
||||
_LOGGER.error("Error rotating history: %s", e)
|
||||
_LOGGER.debug(traceback.format_exc())
|
||||
|
||||
async def _check_history_directory(self) -> None:
|
||||
@@ -639,9 +587,10 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
f"{self.normalized_name}_history_{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
)
|
||||
|
||||
# Rotate files
|
||||
# Rotate files (shutil.move for cross-device safety)
|
||||
import shutil
|
||||
await self.hass.async_add_executor_job(
|
||||
os.rename,
|
||||
shutil.move,
|
||||
self._history_file,
|
||||
archive_file
|
||||
)
|
||||
@@ -662,37 +611,36 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
async def _async_update_data(self) -> Dict[str, Any]:
|
||||
"""Update coordinator data with improved error handling and performance."""
|
||||
try:
|
||||
async with asyncio.Semaphore(1):
|
||||
current_state = self._get_current_state()
|
||||
current_state = self._get_current_state()
|
||||
|
||||
# Get limited history with info
|
||||
history_data = await self._get_limited_history()
|
||||
# Get limited history with info
|
||||
history_data = await self._get_limited_history()
|
||||
|
||||
metrics = await self._get_current_metrics()
|
||||
if metrics is None:
|
||||
metrics = {}
|
||||
metrics = await self._get_current_metrics()
|
||||
if metrics is None:
|
||||
metrics = {}
|
||||
|
||||
data = {
|
||||
"state": current_state,
|
||||
"metrics": metrics,
|
||||
"last_response": await self._get_sanitized_last_response(),
|
||||
"is_processing": self._is_processing,
|
||||
"is_rate_limited": self._is_rate_limited,
|
||||
"is_maintenance": self._is_maintenance,
|
||||
"endpoint_status": self.endpoint_status,
|
||||
"uptime": self._calculate_uptime(),
|
||||
"system_prompt": self._get_truncated_system_prompt(),
|
||||
"history_size": len(self._conversation_history),
|
||||
"conversation_history": history_data["entries"],
|
||||
"history_info": history_data["info"],
|
||||
"normalized_name": self.normalized_name,
|
||||
}
|
||||
data = {
|
||||
"state": current_state,
|
||||
"metrics": metrics,
|
||||
"last_response": await self._get_sanitized_last_response(),
|
||||
"is_processing": self._is_processing,
|
||||
"is_rate_limited": self._is_rate_limited,
|
||||
"is_maintenance": self._is_maintenance,
|
||||
"endpoint_status": self.endpoint_status,
|
||||
"uptime": self._calculate_uptime(),
|
||||
"system_prompt": self._get_truncated_system_prompt(),
|
||||
"history_size": len(self._conversation_history),
|
||||
"conversation_history": history_data["entries"],
|
||||
"history_info": history_data["info"],
|
||||
"normalized_name": self.normalized_name,
|
||||
}
|
||||
|
||||
await self._validate_update_data(data)
|
||||
return data
|
||||
await self._validate_update_data(data)
|
||||
return data
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.error(f"Error updating data: {err}", exc_info=True)
|
||||
_LOGGER.error("Error updating data: %s", err, exc_info=True)
|
||||
return self._get_safe_initial_state()
|
||||
|
||||
async def _get_limited_history(self) -> Dict[str, Any]:
|
||||
@@ -712,7 +660,6 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
"total_entries": len(self._conversation_history),
|
||||
"displayed_entries": len(limited_history),
|
||||
"full_history_available": True,
|
||||
"history_path": self._history_file,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -764,21 +711,14 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
raise ValueError("Invalid metrics format")
|
||||
|
||||
async def async_update_ha_state(self) -> None:
|
||||
"""Update Home Assistant state."""
|
||||
"""Update Home Assistant state via coordinator refresh."""
|
||||
try:
|
||||
_LOGGER.debug(
|
||||
f"Requesting state update for {self.instance_name} (normalized: {self.normalized_name})"
|
||||
"Requesting state update for %s", self.instance_name,
|
||||
)
|
||||
await self.async_request_refresh()
|
||||
|
||||
# Force update of all entities
|
||||
entity_id_base = f"sensor.ha_text_ai_{self.normalized_name.lower()}"
|
||||
for entity_id in self.hass.states.async_entity_ids():
|
||||
if entity_id.startswith(entity_id_base):
|
||||
self.hass.states.async_set(entity_id, self._get_current_state())
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.error(f"Error updating HA state for {self.instance_name}: {err}")
|
||||
_LOGGER.error("Error updating HA state for %s: %s", self.instance_name, err)
|
||||
|
||||
def _get_current_state(self) -> str:
|
||||
"""Get current state based on internal flags."""
|
||||
@@ -860,61 +800,56 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
if self.client is None:
|
||||
raise HomeAssistantError("AI client not initialized")
|
||||
|
||||
try:
|
||||
self._is_processing = True
|
||||
await self.async_update_ha_state()
|
||||
async with self._request_lock:
|
||||
try:
|
||||
self._is_processing = True
|
||||
await self.async_update_ha_state()
|
||||
|
||||
temp_context_messages = context_messages or self.context_messages
|
||||
temp_model = model or self.model
|
||||
temp_temperature = temperature or self.temperature
|
||||
temp_max_tokens = max_tokens or self.max_tokens
|
||||
temp_system_prompt = system_prompt or self._system_prompt
|
||||
temp_context_messages = context_messages if context_messages is not None else self.context_messages
|
||||
temp_model = model if model is not None else self.model
|
||||
temp_temperature = temperature if temperature is not None else self.temperature
|
||||
temp_max_tokens = max_tokens if max_tokens is not None else self.max_tokens
|
||||
temp_system_prompt = system_prompt if system_prompt is not None else self._system_prompt
|
||||
|
||||
# Start timing
|
||||
start_time = dt_util.utcnow()
|
||||
start_time = dt_util.utcnow()
|
||||
|
||||
# Prepare messages with system prompt
|
||||
messages = []
|
||||
if temp_system_prompt:
|
||||
messages.append({"role": "system", "content": temp_system_prompt})
|
||||
messages = []
|
||||
if temp_system_prompt:
|
||||
messages.append({"role": "system", "content": temp_system_prompt})
|
||||
|
||||
# Add context from history
|
||||
context_history = self._conversation_history[-temp_context_messages:]
|
||||
for entry in context_history:
|
||||
messages.append({"role": "user", "content": entry["question"]})
|
||||
messages.append({"role": "assistant", "content": entry["response"]})
|
||||
context_history = self._conversation_history[-temp_context_messages:]
|
||||
for entry in context_history:
|
||||
messages.append({"role": "user", "content": entry["question"]})
|
||||
messages.append({"role": "assistant", "content": entry["response"]})
|
||||
|
||||
# Add current question
|
||||
messages.append({"role": "user", "content": question})
|
||||
messages.append({"role": "user", "content": question})
|
||||
|
||||
# Process message
|
||||
kwargs = {
|
||||
"model": temp_model,
|
||||
"temperature": temp_temperature,
|
||||
"max_tokens": temp_max_tokens,
|
||||
"messages": messages,
|
||||
"structured_output": structured_output,
|
||||
"json_schema": json_schema,
|
||||
}
|
||||
kwargs = {
|
||||
"model": temp_model,
|
||||
"temperature": temp_temperature,
|
||||
"max_tokens": temp_max_tokens,
|
||||
"messages": messages,
|
||||
"structured_output": structured_output,
|
||||
"json_schema": json_schema,
|
||||
}
|
||||
|
||||
response = await self.async_process_message(question, **kwargs)
|
||||
response = await self.async_process_message(question, **kwargs)
|
||||
|
||||
# Update metrics
|
||||
end_time = dt_util.utcnow()
|
||||
latency = (end_time - start_time).total_seconds()
|
||||
await self._update_metrics(latency, response)
|
||||
end_time = dt_util.utcnow()
|
||||
latency = (end_time - start_time).total_seconds()
|
||||
await self._update_metrics(latency, response)
|
||||
|
||||
await self._update_history(question, response)
|
||||
await self._update_history(question, response)
|
||||
|
||||
return response
|
||||
return response
|
||||
|
||||
except Exception as err:
|
||||
await self._handle_error(err)
|
||||
raise HomeAssistantError(f"Failed to process question: {err}")
|
||||
except Exception as err:
|
||||
await self._handle_error(err)
|
||||
raise HomeAssistantError(f"Failed to process question: {err}")
|
||||
|
||||
finally:
|
||||
self._is_processing = False
|
||||
await self.async_update_ha_state()
|
||||
finally:
|
||||
self._is_processing = False
|
||||
await self.async_update_ha_state()
|
||||
|
||||
async def async_process_message(self, question: str, **kwargs) -> dict:
|
||||
"""Process message using the AI client."""
|
||||
@@ -929,6 +864,10 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
json_schema=json_schema, **kwargs
|
||||
)
|
||||
|
||||
# Reset error state on success
|
||||
self._is_rate_limited = False
|
||||
self.endpoint_status = "ready"
|
||||
|
||||
# Add timestamp and model information to response
|
||||
timestamp = dt_util.utcnow().isoformat()
|
||||
model_used = kwargs.get("model", self.model)
|
||||
@@ -1039,9 +978,10 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
async with AsyncFileHandler(self._history_file, 'w') as f:
|
||||
await f.write(json.dumps(history_entries, indent=2))
|
||||
|
||||
# Backup old file
|
||||
# Backup old file (use shutil.move for cross-device safety)
|
||||
import shutil
|
||||
backup_file = old_history_file + '.backup'
|
||||
os.rename(old_history_file, backup_file)
|
||||
await self.hass.async_add_executor_job(shutil.move, old_history_file, backup_file)
|
||||
|
||||
_LOGGER.info(
|
||||
f"Successfully migrated {len(history_entries)} entries "
|
||||
@@ -1132,25 +1072,6 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
self._system_prompt = prompt
|
||||
await self.async_update_ha_state()
|
||||
|
||||
def _check_memory_available(self) -> bool:
|
||||
"""Check if enough memory is available."""
|
||||
try:
|
||||
memory = psutil.virtual_memory()
|
||||
|
||||
# Log the total and available memory
|
||||
_LOGGER.debug("Total memory: %s, Available memory: %s", memory.total, memory.available)
|
||||
|
||||
if memory.available > 1024 * 1024 * 100: # 100MB
|
||||
_LOGGER.debug("Sufficient memory available: %s bytes", memory.available)
|
||||
return True
|
||||
else:
|
||||
_LOGGER.warning("Insufficient memory available: %s bytes", memory.available)
|
||||
return False
|
||||
except Exception as e:
|
||||
_LOGGER.error("Error checking memory availability: %s", e)
|
||||
return True # Assume memory is available if check fails
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shutdown coordinator."""
|
||||
_LOGGER.debug(f"Shutting down coordinator for {self.instance_name}")
|
||||
self.hass.data[DOMAIN].pop(self.instance_name, None)
|
||||
_LOGGER.debug("Shutting down coordinator for %s", self.instance_name)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"domain": "ha_text_ai",
|
||||
"name": "HA Text AI",
|
||||
"after_dependencies": ["http"],
|
||||
"bluetooth": [],
|
||||
"codeowners": ["@smkrv"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
@@ -11,20 +10,10 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
|
||||
"loggers": ["custom_components.ha_text_ai"],
|
||||
"mqtt": [],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"aiofiles>=23.0.0",
|
||||
"aiohttp>=3.8.0",
|
||||
"anthropic>=0.8.0",
|
||||
"async-timeout>=4.0.0",
|
||||
"certifi>=2024.2.2",
|
||||
"google-genai>=1.16.0",
|
||||
"openai>=1.12.0"
|
||||
"aiohttp>=3.8.0"
|
||||
],
|
||||
"single_config_entry": false,
|
||||
"ssdp": [],
|
||||
"usb": [],
|
||||
"version": "2.3.0",
|
||||
"zeroconf": []
|
||||
"version": "2.4.0"
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ from .const import (
|
||||
)
|
||||
|
||||
from .coordinator import HATextAICoordinator
|
||||
from .utils import safe_log_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,23 +84,23 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the HA Text AI sensor."""
|
||||
_LOGGER.debug(f"Starting sensor setup for entry: {entry.entry_id}")
|
||||
_LOGGER.debug("Starting sensor setup for entry: %s", entry.entry_id)
|
||||
|
||||
try:
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
_LOGGER.debug(f"Found coordinator for entry {entry.entry_id}")
|
||||
_LOGGER.debug("Found coordinator for entry %s", entry.entry_id)
|
||||
|
||||
instance_name = coordinator.instance_name
|
||||
_LOGGER.debug(f"Setting up sensor with instance: {instance_name}")
|
||||
_LOGGER.debug("Setting up sensor with instance: %s", instance_name)
|
||||
|
||||
sensor = HATextAISensor(coordinator, entry)
|
||||
_LOGGER.debug(f"Created sensor instance: {sensor.entity_id}")
|
||||
_LOGGER.debug("Created sensor instance: %s", sensor.entity_id)
|
||||
|
||||
async_add_entities([sensor], True)
|
||||
_LOGGER.debug(f"Added sensor entity: {sensor.entity_id}")
|
||||
_LOGGER.debug("Added sensor entity: %s", sensor.entity_id)
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.exception(f"Error setting up sensor: {err}")
|
||||
_LOGGER.exception("Error setting up sensor: %s", err)
|
||||
raise
|
||||
|
||||
class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
@@ -113,7 +114,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
_LOGGER.debug(f"Initializing sensor with config entry: {config_entry.data}")
|
||||
_LOGGER.debug("Initializing sensor with config entry: %s", safe_log_data(dict(config_entry.data)))
|
||||
|
||||
super().__init__(coordinator)
|
||||
|
||||
@@ -121,8 +122,8 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
self._instance_name = coordinator.instance_name
|
||||
self._normalized_name = coordinator.normalized_name
|
||||
|
||||
_LOGGER.debug(f"Instance name: {self._instance_name}")
|
||||
_LOGGER.debug(f"Normalized name: {self._normalized_name}")
|
||||
_LOGGER.debug("Instance name: %s", self._instance_name)
|
||||
_LOGGER.debug("Normalized name: %s", self._normalized_name)
|
||||
|
||||
self._conversation_history = []
|
||||
self._system_prompt = None
|
||||
@@ -131,9 +132,9 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
self.entity_id = f"sensor.ha_text_ai_{self._normalized_name}"
|
||||
self._attr_unique_id = f"{config_entry.entry_id}"
|
||||
|
||||
_LOGGER.debug(f"Created sensor with entity_id: {self.entity_id}")
|
||||
_LOGGER.debug(f"Sensor name: {self._attr_name}")
|
||||
_LOGGER.debug(f"Unique ID: {self._attr_unique_id}")
|
||||
_LOGGER.debug("Created sensor with entity_id: %s", self.entity_id)
|
||||
_LOGGER.debug("Sensor name: %s", self._attr_name)
|
||||
_LOGGER.debug("Unique ID: %s", self._attr_unique_id)
|
||||
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key=f"ha_text_ai_{self._normalized_name.lower()}",
|
||||
@@ -160,7 +161,8 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
f"Initialized sensor: {self.entity_id} for instance: {self._instance_name}"
|
||||
"Initialized sensor: %s for instance: %s",
|
||||
self.entity_id, self._instance_name,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -200,7 +202,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
]
|
||||
|
||||
metrics_values = {k: sanitized.get(k) for k in metrics_keys if k in sanitized}
|
||||
_LOGGER.debug(f"Metrics for {self.entity_id}: {metrics_values}")
|
||||
_LOGGER.debug("Metrics for %s: %s", self.entity_id, metrics_values)
|
||||
|
||||
return sanitized
|
||||
|
||||
@@ -302,7 +304,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._handle_coordinator_update()
|
||||
_LOGGER.debug(f"Entity {self.entity_id} added to Home Assistant")
|
||||
_LOGGER.debug("Entity %s added to Home Assistant", self.entity_id)
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
@@ -310,7 +312,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
data = self.coordinator.data
|
||||
if not self.coordinator.last_update_success or not data:
|
||||
self._current_state = STATE_DISCONNECTED
|
||||
_LOGGER.warning(f"No data available for {self.entity_id}")
|
||||
_LOGGER.warning("No data available for %s", self.entity_id)
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
@@ -320,7 +322,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
metrics = data.get("metrics", {})
|
||||
if isinstance(metrics, dict):
|
||||
self._metrics.update(metrics)
|
||||
_LOGGER.debug(f"Updated metrics for {self.entity_id}: {self._metrics}")
|
||||
_LOGGER.debug("Updated metrics for %s: %s", self.entity_id, self._metrics)
|
||||
|
||||
# Update conversation history and system prompt
|
||||
self._conversation_history = data.get("conversation_history", [])
|
||||
@@ -344,8 +346,8 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
self._last_update = dt_util.utcnow()
|
||||
|
||||
_LOGGER.debug(
|
||||
f"Updated {self.entity_id} state to: {self._current_state} "
|
||||
f"(available: {self.available})"
|
||||
"Updated %s state to: %s (available: %s)",
|
||||
self.entity_id, self._current_state, self.available,
|
||||
)
|
||||
|
||||
except Exception as err:
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"provider": {
|
||||
"title": "Provider Settings",
|
||||
"description": "Provide connection details for your chosen AI provider.",
|
||||
"data": {
|
||||
"name": "Instance name (e.g., 'GPT Assistant', 'Claude Helper')",
|
||||
"api_key": "API key for authentication",
|
||||
"model": "AI model to use",
|
||||
"api_endpoint": "Custom API endpoint URL (optional)",
|
||||
"temperature": "Response creativity (0-2, lower = more focused)",
|
||||
"max_tokens": "Maximum response length (1-100000 tokens)",
|
||||
"request_interval": "Minimum time between requests (0.1-60 seconds)",
|
||||
"api_timeout": "API request timeout in seconds (5-600)",
|
||||
"context_messages": "Number of context messages to retain (1-20)",
|
||||
"max_history_size": "Maximum conversation history size (1-100)"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "Configure HA Text AI Instance",
|
||||
"description": "Set up a new AI assistant instance with your selected provider.",
|
||||
"data": {
|
||||
"name": "Instance name (e.g., 'GPT Assistant', 'Claude Helper')",
|
||||
"api_key": "API key for authentication",
|
||||
"model": "AI model to use",
|
||||
"temperature": "Response creativity (0-2, lower = more focused)",
|
||||
"max_tokens": "Maximum response length (1-100000 tokens)",
|
||||
"api_endpoint": "Custom API endpoint URL (optional)",
|
||||
"api_provider": "API Provider",
|
||||
"request_interval": "Minimum time between requests (0.1-60 seconds)",
|
||||
"api_timeout": "API request timeout in seconds (5-600)",
|
||||
"context_messages": "Number of context messages to retain (1-20)",
|
||||
"max_history_size": "Maximum conversation history size (1-100)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"history_storage_error": "Failed to initialize history storage. Check permissions.",
|
||||
"history_rotation_error": "Error during history file rotation.",
|
||||
"history_file_access_error": "Cannot access history storage directory.",
|
||||
"name_exists": "An instance with this name already exists",
|
||||
"invalid_name": "Invalid instance name",
|
||||
"invalid_auth": "Authentication failed - check your API key",
|
||||
"invalid_api_key": "Invalid API key - please verify your credentials",
|
||||
"cannot_connect": "Failed to connect to API service",
|
||||
"invalid_model": "Selected model is not available",
|
||||
"rate_limit": "Rate limit exceeded",
|
||||
"context_length": "Context length exceeded",
|
||||
"rate_limit_exceeded": "API rate limit exceeded",
|
||||
"maintenance": "Service is under maintenance",
|
||||
"invalid_response": "Invalid API response received",
|
||||
"api_error": "API service error occurred",
|
||||
"timeout": "Request timed out",
|
||||
"invalid_instance": "Invalid instance specified",
|
||||
"unknown": "Unexpected error occurred",
|
||||
"empty": "Name cannot be empty",
|
||||
"invalid_characters": "Name can only contain letters, numbers, spaces, underscores and hyphens",
|
||||
"name_too_long": "Name must be 50 characters or less"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Instance already configured"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Select Provider",
|
||||
"description": "Choose the AI provider for this instance. The integration will reload after saving changes.",
|
||||
"data": {
|
||||
"api_provider": "API Provider"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Connection & Model Settings",
|
||||
"description": "Configure API credentials and model parameters. Changes will take effect after the integration reloads.",
|
||||
"data": {
|
||||
"api_key": "API Key",
|
||||
"api_endpoint": "API Endpoint URL",
|
||||
"model": "AI model",
|
||||
"temperature": "Response creativity (0-2)",
|
||||
"max_tokens": "Maximum response length (1-100000)",
|
||||
"request_interval": "Minimum request interval (0.1-60 seconds)",
|
||||
"api_timeout": "API request timeout in seconds (5-600)",
|
||||
"context_messages": "Number of previous messages to include in context (1-20)",
|
||||
"max_history_size": "Maximum conversation history size (1-100)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"api_provider": {
|
||||
"options": {
|
||||
"openai": "OpenAI (compatible)",
|
||||
"anthropic": "Anthropic (compatible)",
|
||||
"deepseek": "DeepSeek",
|
||||
"gemini": "Google Gemini"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"ask_question": {
|
||||
"name": "Ask Question (HA Text AI)",
|
||||
"description": "Send a question to the AI model and receive a detailed response. This service now returns response data directly, eliminating the need for separate text sensors and the 255-character limitation. The response will also be stored in the conversation history.",
|
||||
"fields": {
|
||||
"instance": {
|
||||
"name": "Instance",
|
||||
"description": "Name of the HA Text AI instance to use"
|
||||
},
|
||||
"question": {
|
||||
"name": "Question",
|
||||
"description": "Your question or prompt for the AI assistant"
|
||||
},
|
||||
"context_messages": {
|
||||
"name": "Context Messages",
|
||||
"description": "Number of previous messages to include in context (1-20)"
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "System Prompt",
|
||||
"description": "Optional system prompt to set context for this specific question"
|
||||
},
|
||||
"model": {
|
||||
"name": "Model",
|
||||
"description": "Select AI model to use (optional, overrides default setting)"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature",
|
||||
"description": "Controls response creativity (0.0-2.0)"
|
||||
},
|
||||
"max_tokens": {
|
||||
"name": "Max Tokens",
|
||||
"description": "Maximum length of the response (1-100000 tokens)"
|
||||
},
|
||||
"structured_output": {
|
||||
"name": "Structured Output",
|
||||
"description": "Enable JSON structured output mode. When enabled, the AI will respond with valid JSON matching the provided schema."
|
||||
},
|
||||
"json_schema": {
|
||||
"name": "JSON Schema",
|
||||
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
|
||||
}
|
||||
}
|
||||
},
|
||||
"clear_history": {
|
||||
"name": "Clear History",
|
||||
"description": "Delete all stored questions and responses from the conversation history",
|
||||
"fields": {
|
||||
"instance": {
|
||||
"name": "Instance",
|
||||
"description": "Name of the HA Text AI instance to clear history for"
|
||||
}
|
||||
}
|
||||
},
|
||||
"get_history": {
|
||||
"name": "Get History",
|
||||
"description": "Retrieve conversation history with optional filtering and sorting",
|
||||
"fields": {
|
||||
"instance": {
|
||||
"name": "Instance",
|
||||
"description": "Name of the HA Text AI instance to get history from"
|
||||
},
|
||||
"limit": {
|
||||
"name": "Limit",
|
||||
"description": "Number of conversations to return (1-100)"
|
||||
},
|
||||
"filter_model": {
|
||||
"name": "Filter Model",
|
||||
"description": "Filter conversations by specific AI model"
|
||||
},
|
||||
"start_date": {
|
||||
"name": "Start Date",
|
||||
"description": "Filter conversations starting from this date/time"
|
||||
},
|
||||
"include_metadata": {
|
||||
"name": "Include Metadata",
|
||||
"description": "Include additional information like tokens used, response time, etc."
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "Sort Order",
|
||||
"description": "Sort order for results (newest or oldest first)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_system_prompt": {
|
||||
"name": "Set System Prompt",
|
||||
"description": "Set default system behavior instructions for all future conversations",
|
||||
"fields": {
|
||||
"instance": {
|
||||
"name": "Instance",
|
||||
"description": "Name of the HA Text AI instance to set system prompt for"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "System Prompt",
|
||||
"description": "Instructions that define how the AI should behave and respond"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ha_text_ai": {
|
||||
"name": "{name}",
|
||||
"state": {
|
||||
"ready": "Ready",
|
||||
"processing": "Processing",
|
||||
"error": "Error",
|
||||
"disconnected": "Disconnected",
|
||||
"rate_limited": "Rate Limited",
|
||||
"maintenance": "Maintenance",
|
||||
"initializing": "Initializing",
|
||||
"retrying": "Retrying",
|
||||
"queued": "Queued"
|
||||
},
|
||||
"state_attributes": {
|
||||
"question": {
|
||||
"name": "Last Question"
|
||||
},
|
||||
"response": {
|
||||
"name": "Last Response"
|
||||
},
|
||||
"model": {
|
||||
"name": "Current Model"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
"max_tokens": {
|
||||
"name": "Max Tokens"
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "System Prompt"
|
||||
},
|
||||
"response_time": {
|
||||
"name": "Last Response Time"
|
||||
},
|
||||
"total_responses": {
|
||||
"name": "Total Responses"
|
||||
},
|
||||
"error_count": {
|
||||
"name": "Error Count"
|
||||
},
|
||||
"last_error": {
|
||||
"name": "Last Error"
|
||||
},
|
||||
"api_status": {
|
||||
"name": "API Status"
|
||||
},
|
||||
"tokens_used": {
|
||||
"name": "Total Tokens Used"
|
||||
},
|
||||
"average_response_time": {
|
||||
"name": "Average Response Time"
|
||||
},
|
||||
"last_request_time": {
|
||||
"name": "Last Request Time"
|
||||
},
|
||||
"is_processing": {
|
||||
"name": "Processing Status"
|
||||
},
|
||||
"is_rate_limited": {
|
||||
"name": "Rate Limited Status"
|
||||
},
|
||||
"is_maintenance": {
|
||||
"name": "Maintenance Status"
|
||||
},
|
||||
"api_version": {
|
||||
"name": "API Version"
|
||||
},
|
||||
"endpoint_status": {
|
||||
"name": "Endpoint Status"
|
||||
},
|
||||
"performance_metrics": {
|
||||
"name": "Performance Metrics"
|
||||
},
|
||||
"history_size": {
|
||||
"name": "History Size"
|
||||
},
|
||||
"uptime": {
|
||||
"name": "Uptime"
|
||||
},
|
||||
"total_tokens": {
|
||||
"name": "Total Tokens"
|
||||
},
|
||||
"prompt_tokens": {
|
||||
"name": "Prompt Tokens"
|
||||
},
|
||||
"completion_tokens": {
|
||||
"name": "Completion Tokens"
|
||||
},
|
||||
"successful_requests": {
|
||||
"name": "Successful Requests"
|
||||
},
|
||||
"failed_requests": {
|
||||
"name": "Failed Requests"
|
||||
},
|
||||
"average_latency": {
|
||||
"name": "Average Latency"
|
||||
},
|
||||
"max_latency": {
|
||||
"name": "Maximum Latency"
|
||||
},
|
||||
"min_latency": {
|
||||
"name": "Minimum Latency"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"provider": {
|
||||
"title": "Wählen Sie AI-Anbieter",
|
||||
"description": "Wählen Sie, welchen AI-Dienstanbieter Sie für diese Instanz verwenden möchten.",
|
||||
"data": {
|
||||
"api_provider": "API-Anbieter",
|
||||
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
|
||||
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)"
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"title": "Anbieter-Einstellungen",
|
||||
"description": "Geben Sie die Verbindungsdetails für Ihren gewählten AI-Anbieter an.",
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"provider": {
|
||||
"title": "Select AI Provider",
|
||||
"description": "Choose which AI service provider to use for this instance.",
|
||||
"data": {
|
||||
"api_provider": "API Provider",
|
||||
"context_messages": "Number of context messages to retain (1-20)",
|
||||
"max_history_size": "Maximum conversation history size (1-100)"
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"title": "Provider Settings",
|
||||
"description": "Provide connection details for your chosen AI provider.",
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"provider": {
|
||||
"title": "Seleccionar proveedor de IA",
|
||||
"description": "Elige qué proveedor de servicio de IA utilizar para esta instancia.",
|
||||
"data": {
|
||||
"api_provider": "Proveedor de API",
|
||||
"context_messages": "Número de mensajes de contexto a retener (1-20)",
|
||||
"max_history_size": "Tamaño máximo del historial de conversación (1-100)"
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"title": "Configuración del proveedor",
|
||||
"description": "Proporciona los detalles de conexión para tu proveedor de IA elegido.",
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"provider": {
|
||||
"title": "Seleziona fornitore AI",
|
||||
"description": "Scegli quale fornitore di servizi AI utilizzare per questa istanza.",
|
||||
"data": {
|
||||
"api_provider": "Fornitore API",
|
||||
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
|
||||
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)"
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"title": "Impostazioni fornitore",
|
||||
"description": "Fornisci i dettagli di connessione per il tuo fornitore di AI scelto.",
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"provider": {
|
||||
"title": "Выбор провайдера ИИ",
|
||||
"description": "Выберите сервис искусственного интеллекта для этого экземпляра.",
|
||||
"data": {
|
||||
"api_provider": "Провайдер API",
|
||||
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
|
||||
"max_history_size": "Максимальный размер истории разговора (1-100)"
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"title": "Настройки провайдера",
|
||||
"description": "Укажите параметры подключения для выбранного провайдера ИИ.",
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
"ready": "Спремно",
|
||||
"processing": "Обрада",
|
||||
"error": "Грешка",
|
||||
"disconnected": "Прекључено",
|
||||
"disconnected": "Искључено",
|
||||
"rate_limited": "Ограничење захтева",
|
||||
"maintenance": "Одржавање",
|
||||
"initializing": "Инициализује се",
|
||||
|
||||
@@ -6,8 +6,9 @@ Utility functions for HA Text AI integration.
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
"""
|
||||
import hashlib
|
||||
import ipaddress
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
|
||||
@@ -19,18 +20,41 @@ def normalize_name(name: str) -> str:
|
||||
return normalized.lower()
|
||||
|
||||
|
||||
def get_file_hash(file_path: str) -> str:
|
||||
"""Calculate SHA256 hash of file."""
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
def safe_log_data(
|
||||
data: dict[str, Any],
|
||||
sensitive_keys: tuple[str, ...] = (CONF_API_KEY,),
|
||||
) -> dict[str, Any]:
|
||||
"""Filter sensitive keys from data for safe logging."""
|
||||
return {k: "***" if k in sensitive_keys else v for k, v in data.items()}
|
||||
|
||||
|
||||
|
||||
def validate_endpoint(endpoint: str) -> str:
|
||||
"""Validate API endpoint URL for security.
|
||||
|
||||
Ensures HTTPS-only and blocks private/reserved IP ranges (SSRF protection).
|
||||
Returns the validated endpoint stripped of trailing slashes.
|
||||
|
||||
Raises:
|
||||
ValueError: If the endpoint fails validation.
|
||||
"""
|
||||
parsed = urlparse(endpoint)
|
||||
|
||||
if parsed.scheme not in ("https",):
|
||||
raise ValueError("Only HTTPS endpoints are allowed")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise ValueError("Invalid endpoint URL: no hostname")
|
||||
|
||||
# Block private/reserved IPs
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
if addr.is_private or addr.is_reserved or addr.is_loopback or addr.is_link_local:
|
||||
raise ValueError("Private/reserved IP addresses are not allowed")
|
||||
except ValueError as e:
|
||||
if "not allowed" in str(e):
|
||||
raise
|
||||
# Not an IP address — it's a hostname, which is fine
|
||||
|
||||
return endpoint.rstrip("/")
|
||||
|
||||
Reference in New Issue
Block a user