diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index cbb47fd..284fe14 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -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: diff --git a/custom_components/ha_text_ai/api_client.py b/custom_components/ha_text_ai/api_client.py index 7e05ec4..b1a4c22 100644 --- a/custom_components/ha_text_ai/api_client.py +++ b/custom_components/ha_text_ai/api_client.py @@ -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, diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 73fa2e4..9042680 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -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) diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index cb245a0..bc02155 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -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) diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py index d835878..998f819 100644 --- a/custom_components/ha_text_ai/coordinator.py +++ b/custom_components/ha_text_ai/coordinator.py @@ -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) diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index 80fcb39..0ead9ea 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -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" } diff --git a/custom_components/ha_text_ai/sensor.py b/custom_components/ha_text_ai/sensor.py index 2e7a023..31b4d67 100644 --- a/custom_components/ha_text_ai/sensor.py +++ b/custom_components/ha_text_ai/sensor.py @@ -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: diff --git a/custom_components/ha_text_ai/strings.json b/custom_components/ha_text_ai/strings.json new file mode 100644 index 0000000..ff62875 --- /dev/null +++ b/custom_components/ha_text_ai/strings.json @@ -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" + } + } + } + } + } +} diff --git a/custom_components/ha_text_ai/translations/de.json b/custom_components/ha_text_ai/translations/de.json index 1061de9..96d6d44 100644 --- a/custom_components/ha_text_ai/translations/de.json +++ b/custom_components/ha_text_ai/translations/de.json @@ -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.", diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index b3b6187..ff62875 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -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.", diff --git a/custom_components/ha_text_ai/translations/es.json b/custom_components/ha_text_ai/translations/es.json index f33de77..86d3323 100644 --- a/custom_components/ha_text_ai/translations/es.json +++ b/custom_components/ha_text_ai/translations/es.json @@ -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.", diff --git a/custom_components/ha_text_ai/translations/it.json b/custom_components/ha_text_ai/translations/it.json index 087563a..c4cbeca 100644 --- a/custom_components/ha_text_ai/translations/it.json +++ b/custom_components/ha_text_ai/translations/it.json @@ -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.", diff --git a/custom_components/ha_text_ai/translations/ru.json b/custom_components/ha_text_ai/translations/ru.json index 35882fe..aa48c64 100644 --- a/custom_components/ha_text_ai/translations/ru.json +++ b/custom_components/ha_text_ai/translations/ru.json @@ -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": "Укажите параметры подключения для выбранного провайдера ИИ.", diff --git a/custom_components/ha_text_ai/translations/sr.json b/custom_components/ha_text_ai/translations/sr.json index a3d2d59..aa7397d 100644 --- a/custom_components/ha_text_ai/translations/sr.json +++ b/custom_components/ha_text_ai/translations/sr.json @@ -204,7 +204,7 @@ "ready": "Спремно", "processing": "Обрада", "error": "Грешка", - "disconnected": "Прекључено", + "disconnected": "Искључено", "rate_limited": "Ограничење захтева", "maintenance": "Одржавање", "initializing": "Инициализује се", diff --git a/custom_components/ha_text_ai/utils.py b/custom_components/ha_text_ai/utils.py index 41adcfe..66331b3 100644 --- a/custom_components/ha_text_ai/utils.py +++ b/custom_components/ha_text_ai/utils.py @@ -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("/")