diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml index 5992c55..694d189 100644 --- a/.github/workflows/hassfest.yaml +++ b/.github/workflows/hassfest.yaml @@ -2,13 +2,44 @@ name: Validate with hassfest on: push: + branches: + - main + - dev + paths: + - 'custom_components/**' + - '.github/workflows/**' pull_request: + branches: + - main + paths: + - 'custom_components/**' + - '.github/workflows/**' schedule: - - cron: "0 0 * * *" + - cron: "0 0 * * *" # Run daily at midnight UTC + workflow_dispatch: # Allow manual trigger jobs: validate: + name: Validate with hassfest runs-on: ubuntu-latest + timeout-minutes: 10 + steps: - - uses: actions/checkout@v3 - - uses: home-assistant/actions/hassfest@master + - name: โคต๏ธ Check out code from GitHub + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ๐Ÿš€ Run hassfest validation + uses: home-assistant/actions/hassfest@master + with: + strict: true + + - name: โ„น๏ธ Print hassfest version + if: always() + run: | + echo "Hassfest version: $(hassfest --version)" + + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml deleted file mode 100644 index a929b5d..0000000 --- a/.github/workflows/validate.yaml +++ /dev/null @@ -1,24 +0,0 @@ -name: Validate - -on: - push: - pull_request: - schedule: - - cron: "0 0 * * *" - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run tests - run: | - pytest diff --git a/README.md b/README.md index 8f388c9..565c235 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,165 @@ -# ๐Ÿค– HA text AI Integration for Home Assistant +# ๐Ÿค– HA Text AI for Home Assistant
-![GitHub release](https://img.shields.io/github/release/smkrv/ha-text-ai.svg) +![GitHub release](https://img.shields.io/github/release/smkrv/ha-text-ai.svg?style=flat-square) +![GitHub downloads](https://img.shields.io/github/downloads/smkrv/ha-text-ai/total.svg?style=flat-square) ![GitHub stars](https://img.shields.io/github/stars/smkrv/ha-text-ai.svg?style=social) -![GitHub forks](https://img.shields.io/github/forks/smkrv/ha-text-ai.svg?style=social) -![GitHub issues](https://img.shields.io/github/issues/smkrv/ha-text-ai.svg) -[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/hacs/integration) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +![GitHub last commit](https://img.shields.io/github/last-commit/smkrv/ha-text-ai.svg?style=flat-square) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)

-Powerful OpenAI integration for Home Assistant enabling natural language interaction with your smart home +Transform your smart home experience with powerful AI assistance powered by OpenAI's GPT models

--- -## ๐Ÿ“‹ Table of Contents -- [Features](#-features) -- [Installation](#-installation) -- [Configuration](#-configuration) -- [Services](#-services) -- [Advanced Usage](#-advanced-usage) -- [Troubleshooting](#-troubleshooting) -- [Contributing](#-contributing) +## ๐ŸŒŸ Features -## โœจ Features +- ๐Ÿง  **Advanced AI Integration**: Leverage OpenAI's powerful models (GPT-3.5, GPT-4) for smart home interactions +- ๐Ÿ’ฌ **Natural Language Control**: Control your home and get information using everyday language +- ๐Ÿ“ **Conversation Memory**: Maintain context with conversation history tracking +- โšก **Real-time Responses**: Get quick, contextual responses to your queries +- ๐ŸŽฏ **Customizable Behavior**: Fine-tune AI responses with adjustable parameters +- ๐Ÿ”’ **Secure Integration**: Your API key and data are handled securely +- ๐ŸŽจ **Flexible Configuration**: Easy setup with multiple configuration options +- ๐Ÿ”„ **Automation Ready**: Integrate AI responses into your automations -- ๐Ÿ”„ **Real-time AI Interaction**: Seamless communication with OpenAI's latest models -- ๐Ÿ“ **Conversation History**: Track and manage your AI interactions -- โš™๏ธ **Customizable Settings**: Fine-tune AI behavior with adjustable parameters -- ๐Ÿ”Œ **Easy Integration**: Simple setup process through HACS or manual installation -- ๐ŸŽฏ **System Prompts**: Set context for more relevant AI responses +## ๐Ÿ“‹ Prerequisites -## ๐Ÿš€ Installation +- Home Assistant installation (Core, OS, Container, or Supervised) +- OpenAI API key ([Get one here](https://platform.openai.com/account/api-keys)) +- Python 3.9 or newer -### HACS Installation (Recommended) -1. Ensure [HACS](https://hacs.xyz/) is installed -2. Search for "HA text AI" in HACS -3. Click Install -4. Restart Home Assistant +## โšก Quick Start -
-Manual Installation Steps - -```bash -# 1. Navigate to your Home Assistant configuration directory -cd ~/.homeassistant - -# 2. Create custom_components directory if it doesn't exist -mkdir -p custom_components - -# 3. Clone the repository -git clone https://github.com/smkrv/ha-text-ai.git custom_components/ha_text_ai - -# 4. Restart Home Assistant -``` -
- -## โš™๏ธ Configuration - -### Basic Configuration +### Manual Installation +1. Download the repository +2. Copy `custom_components/ha_text_ai` to your `custom_components` directory +3. Restart Home Assistant +4. Add configuration to `configuration.yaml`: ```yaml ha_text_ai: - api_key: your_openai_api_key - model: gpt-3.5-turbo + api_key: !secret openai_api_key ``` -### Advanced Configuration -```yaml -ha_text_ai: - api_key: your_openai_api_key - model: gpt-4 - temperature: 0.8 - max_tokens: 2000 - api_endpoint: https://custom-endpoint.com/v1 - request_interval: 2.0 -``` +## โš™๏ธ Configuration Options -## ๐Ÿ›  Services +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `api_key` | string | Required | Your OpenAI API key | +| `model` | string | `gpt-3.5-turbo` | AI model to use | +| `temperature` | float | `0.7` | Response creativity (0-2) | +| `max_tokens` | integer | `1000` | Maximum response length | +| `request_interval` | float | `1.0` | Minimum seconds between requests | +| `api_endpoint` | string | OpenAI default | Custom API endpoint URL | -### Ask Question +## ๐Ÿ› ๏ธ Available Services + +### ask_question +Ask the AI assistant a question: ```yaml service: ha_text_ai.ask_question data: - question: "What's the weather like today?" + question: "What's the optimal temperature for sleeping?" model: "gpt-4" # optional - temperature: 0.7 # optional + temperature: 0.5 # optional + max_tokens: 500 # optional ``` -### More Services -- `ha_text_ai.clear_history`: Reset conversation history -- `ha_text_ai.get_history`: Retrieve past interactions -- `ha_text_ai.set_system_prompt`: Configure AI behavior +### set_system_prompt +Configure AI behavior: +```yaml +service: ha_text_ai.set_system_prompt +data: + prompt: "You are a home automation expert focused on energy efficiency" +``` -## ๐Ÿ” Advanced Usage +### clear_history +Reset conversation history: +```yaml +service: ha_text_ai.clear_history +``` -### Automation Example +### get_history +Retrieve conversation history: +```yaml +service: ha_text_ai.get_history +data: + limit: 5 # optional +``` + +## ๐Ÿ”ง Practical Examples + +### Smart Temperature Management +```yaml +automation: + trigger: + platform: time_pattern + hours: "/1" + action: + service: ha_text_ai.ask_question + data: + question: > + Current temperature is {{ states('sensor.living_room_temperature') }}ยฐC. + Should I adjust the thermostat for optimal comfort and energy savings? +``` + +### Smart Lighting Assistant ```yaml automation: trigger: platform: state - entity_id: binary_sensor.motion + entity_id: binary_sensor.living_room_motion to: 'on' + condition: + condition: template + value_template: "{{ states('sensor.illuminance') | float < 10 }}" action: service: ha_text_ai.ask_question data: - question: "What should I do when motion is detected?" + question: > + Motion detected in living room with low light levels. + What's the best lighting scene to set based on the time of day? ``` -## ๐Ÿ”ง Troubleshooting +## โ— Common Issues -
-Common Issues and Solutions +### API Rate Limits +- Increase `request_interval` if hitting rate limits +- Consider upgrading your OpenAI plan +- Use caching for frequent queries -### API Key Issues -- Verify API key format -- Check API key permissions -- Ensure proper configuration in secrets.yaml +### High Token Usage +- Reduce `max_tokens` parameter +- Clear conversation history regularly +- Use focused system prompts -### Connection Problems -- Verify internet connection -- Check API endpoint accessibility -- Review Home Assistant logs -
+### Connection Issues +- Check internet connectivity +- Verify API key validity +- Ensure endpoint accessibility -## ๐Ÿ‘ฅ Contributing +## ๐Ÿค Contributing -We welcome contributions! Here's how you can help: +Contributions are welcome! Please feel free to submit a Pull Request. 1. Fork the repository -2. Create a feature branch -3. Commit your changes -4. Push to the branch -5. Submit a pull request +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## ๐Ÿ“ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ---
-**[Documentation](https://github.com/smkrv/ha-text-ai/wiki)** | **[Report Bug](https://github.com/smkrv/ha-text-ai/issues)** | **[Request Feature](https://github.com/smkrv/ha-text-ai/issues)** +Made with โค๏ธ for the Home Assistant Community
diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index b5e2b70..d281dc8 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -5,10 +5,10 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ConfigEntryNotReady from .const import ( DOMAIN, @@ -28,95 +28,80 @@ from .coordinator import HATextAICoordinator _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: - """Set up the HA text AI component from configuration.yaml.""" + """Set up the HA text AI component.""" hass.data.setdefault(DOMAIN, {}) async def async_ask_question(call: ServiceCall) -> None: - """Handle the ask_question service call. + """Handle the ask_question service call.""" + if not hass.data[DOMAIN]: + raise HomeAssistantError("No AI Text integration configured") + + coordinator = next(iter(hass.data[DOMAIN].values())) + question = call.data["question"] + + + original_params = { + "model": coordinator.model, + "temperature": coordinator.temperature, + "max_tokens": coordinator.max_tokens + } - Args: - call: Service call containing question and optional parameters. - """ try: - # Get the coordinator from the first config entry - if not hass.data[DOMAIN]: - raise HomeAssistantError("No AI Text integration configured") - coordinator = next(iter(hass.data[DOMAIN].values())) + if "model" in call.data: + coordinator.model = call.data["model"] + if "temperature" in call.data: + coordinator.temperature = call.data["temperature"] + if "max_tokens" in call.data: + coordinator.max_tokens = call.data["max_tokens"] - question = call.data["question"] - model = call.data.get("model", coordinator.model) - temperature = call.data.get("temperature", coordinator.temperature) - max_tokens = call.data.get("max_tokens", coordinator.max_tokens) - - # Temporarily update parameters if they were overridden - original_model = coordinator.model - original_temperature = coordinator.temperature - original_max_tokens = coordinator.max_tokens - - try: - coordinator.model = model - coordinator.temperature = temperature - coordinator.max_tokens = max_tokens - await coordinator.async_ask_question(question) - finally: - # Restore original parameters - coordinator.model = original_model - coordinator.temperature = original_temperature - coordinator.max_tokens = original_max_tokens + await coordinator.async_ask_question(question) except Exception as ex: _LOGGER.error("Error asking question: %s", str(ex)) - raise HomeAssistantError(f"Failed to ask question: {str(ex)}") + raise HomeAssistantError(f"Failed to ask question: {str(ex)}") from ex + finally: + + coordinator.model = original_params["model"] + coordinator.temperature = original_params["temperature"] + coordinator.max_tokens = original_params["max_tokens"] async def async_clear_history(call: ServiceCall) -> None: """Handle the clear_history service call.""" - try: - if not hass.data[DOMAIN]: - raise HomeAssistantError("No AI Text integration configured") + if not hass.data[DOMAIN]: + raise HomeAssistantError("No AI Text integration configured") - coordinator = next(iter(hass.data[DOMAIN].values())) - coordinator._responses.clear() - await coordinator.async_refresh() - except Exception as ex: - _LOGGER.error("Error clearing history: %s", str(ex)) - raise HomeAssistantError(f"Failed to clear history: {str(ex)}") + coordinator = next(iter(hass.data[DOMAIN].values())) + coordinator._responses.clear() + await coordinator.async_refresh() async def async_get_history(call: ServiceCall) -> dict[str, list]: - """Handle the get_history service call. + """Handle the get_history service call.""" + if not hass.data[DOMAIN]: + raise HomeAssistantError("No AI Text integration configured") - Returns: - Dictionary containing chat history. - """ - try: - if not hass.data[DOMAIN]: - raise HomeAssistantError("No AI Text integration configured") + coordinator = next(iter(hass.data[DOMAIN].values())) + if not coordinator._responses: + return {"history": []} - coordinator = next(iter(hass.data[DOMAIN].values())) - limit = call.data.get("limit", 10) - history = list(coordinator._responses.items())[-limit:] - return { - "history": [ - {"question": q, "response": r} for q, r in history - ] - } - except Exception as ex: - _LOGGER.error("Error getting history: %s", str(ex)) - raise HomeAssistantError(f"Failed to get history: {str(ex)}") + limit = call.data.get("limit", 10) + history = list(coordinator._responses.items()) + limited_history = history[-limit:] if len(history) > limit else history + + return { + "history": [ + {"question": q, "response": r} for q, r in limited_history + ] + } async def async_set_system_prompt(call: ServiceCall) -> None: """Handle the set_system_prompt service call.""" - try: - if not hass.data[DOMAIN]: - raise HomeAssistantError("No AI Text integration configured") + if not hass.data[DOMAIN]: + raise HomeAssistantError("No AI Text integration configured") + + coordinator = next(iter(hass.data[DOMAIN].values())) + coordinator.system_prompt = call.data["prompt"] - coordinator = next(iter(hass.data[DOMAIN].values())) - prompt = call.data["prompt"] - coordinator.system_prompt = prompt - except Exception as ex: - _LOGGER.error("Error setting system prompt: %s", str(ex)) - raise HomeAssistantError(f"Failed to set system prompt: {str(ex)}") - # Register services hass.services.async_register( DOMAIN, SERVICE_ASK_QUESTION, @@ -176,32 +161,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator return await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) except Exception as ex: - _LOGGER.error("Error setting up entry: %s", str(ex)) - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady(f"Failed to setup entry: {str(ex)}") from ex async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - try: - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) - # Only remove services if this is the last entry - if not hass.data[DOMAIN]: - for service in [ - SERVICE_ASK_QUESTION, - SERVICE_CLEAR_HISTORY, - SERVICE_GET_HISTORY, - SERVICE_SET_SYSTEM_PROMPT - ]: + + if not hass.data[DOMAIN]: + services = [ + SERVICE_ASK_QUESTION, + SERVICE_CLEAR_HISTORY, + SERVICE_GET_HISTORY, + SERVICE_SET_SYSTEM_PROMPT + ] + for service in services: + if service in hass.services.async_services().get(DOMAIN, {}): hass.services.async_remove(DOMAIN, service) - return unload_ok - except Exception as ex: - _LOGGER.error("Error unloading entry: %s", str(ex)) - return False + return unload_ok diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 97f63b0..197497b 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -1,8 +1,12 @@ """Config flow for HA text AI integration.""" +from typing import Any, Dict, Optional import voluptuous as vol + from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv from homeassistant.core import callback +import openai from .const import ( DOMAIN, @@ -18,70 +22,117 @@ from .const import ( DEFAULT_REQUEST_INTERVAL, ) -class HATextAIConfigFlow(config_entries.ConfigFlow): +STEP_USER_DATA_SCHEMA = vol.Schema({ + vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str, + vol.Optional( + CONF_TEMPERATURE, + default=DEFAULT_TEMPERATURE + ): vol.All(vol.Coerce(float), vol.Range(min=0, max=2)), + vol.Optional( + CONF_MAX_TOKENS, + default=DEFAULT_MAX_TOKENS + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=4096)), + vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): str, + vol.Optional( + CONF_REQUEST_INTERVAL, + default=DEFAULT_REQUEST_INTERVAL + ): vol.All(vol.Coerce(float), vol.Range(min=0.1)), +}) + +class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for HA text AI.""" VERSION = 1 - DOMAIN = DOMAIN # Define the domain as a class variable - async def async_step_user(self, user_input=None): + async def async_step_user( + self, + user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """Handle the initial step.""" - errors = {} + errors: Dict[str, str] = {} if user_input is not None: - return self.async_create_entry(title="HA text AI", data=user_input) + try: + + client = openai.OpenAI( + api_key=user_input[CONF_API_KEY], + base_url=user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT) + ) + await self.hass.async_add_executor_job( + client.models.list + ) + + + await self.async_set_unique_id(user_input[CONF_API_KEY]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="HA text AI", + data=user_input + ) + + except openai.AuthenticationError: + errors["base"] = "invalid_auth" + except openai.APIError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" return self.async_show_form( step_id="user", - data_schema=vol.Schema({ - vol.Required("api_key"): str, - vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str, - vol.Optional(CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.Coerce(float), - vol.Optional(CONF_MAX_TOKENS, default=DEFAULT_MAX_TOKENS): vol.Coerce(int), - vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): str, - vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.Coerce(float), - }), + data_schema=STEP_USER_DATA_SCHEMA, errors=errors, ) @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) class OptionsFlowHandler(config_entries.OptionsFlow): """Handle options flow for HA text AI.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, + user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) + options_schema = vol.Schema({ + vol.Optional( + CONF_TEMPERATURE, + default=self.config_entry.options.get( + CONF_TEMPERATURE, DEFAULT_TEMPERATURE + ), + description="Temperature for response generation (0-2)", + ): vol.All(vol.Coerce(float), vol.Range(min=0, max=2)), + vol.Optional( + CONF_MAX_TOKENS, + default=self.config_entry.options.get( + CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS + ), + description="Maximum tokens in response (1-4096)", + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=4096)), + vol.Optional( + CONF_REQUEST_INTERVAL, + default=self.config_entry.options.get( + CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL + ), + description="Minimum time between API requests (seconds)", + ): vol.All(vol.Coerce(float), vol.Range(min=0.1)), + }) + return self.async_show_form( step_id="init", - data_schema=vol.Schema({ - vol.Optional( - CONF_TEMPERATURE, - default=self.config_entry.options.get( - CONF_TEMPERATURE, DEFAULT_TEMPERATURE - ), - ): vol.Coerce(float), - vol.Optional( - CONF_MAX_TOKENS, - default=self.config_entry.options.get( - CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS - ), - ): vol.Coerce(int), - vol.Optional( - CONF_REQUEST_INTERVAL, - default=self.config_entry.options.get( - CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL - ), - ): vol.Coerce(float), - }), + data_schema=options_schema, ) diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index 2f0c23c..9830c15 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -1,30 +1,61 @@ """Constants for the HA text AI integration.""" +from typing import Final from homeassistant.const import Platform -DOMAIN = "ha_text_ai" -PLATFORMS = [Platform.SENSOR] +# Domain +DOMAIN: Final = "ha_text_ai" +PLATFORMS: Final = [Platform.SENSOR] -# Configuration -CONF_MODEL = "model" -CONF_TEMPERATURE = "temperature" -CONF_MAX_TOKENS = "max_tokens" -CONF_API_ENDPOINT = "api_endpoint" -CONF_REQUEST_INTERVAL = "request_interval" +# Configuration constants +CONF_MODEL: Final = "model" +CONF_TEMPERATURE: Final = "temperature" +CONF_MAX_TOKENS: Final = "max_tokens" +CONF_API_ENDPOINT: Final = "api_endpoint" +CONF_REQUEST_INTERVAL: Final = "request_interval" -# Defaults -DEFAULT_MODEL = "gpt-3.5-turbo" -DEFAULT_TEMPERATURE = 0.7 -DEFAULT_MAX_TOKENS = 1000 -DEFAULT_API_ENDPOINT = "https://api.openai.com/v1" -DEFAULT_REQUEST_INTERVAL = 1.0 +# Default values +DEFAULT_MODEL: Final = "gpt-3.5-turbo" +DEFAULT_TEMPERATURE: Final = 0.7 +DEFAULT_MAX_TOKENS: Final = 1000 +DEFAULT_API_ENDPOINT: Final = "https://api.openai.com/v1" +DEFAULT_REQUEST_INTERVAL: Final = 1.0 -# Services -SERVICE_ASK_QUESTION = "ask_question" -SERVICE_CLEAR_HISTORY = "clear_history" -SERVICE_GET_HISTORY = "get_history" -SERVICE_SET_SYSTEM_PROMPT = "set_system_prompt" +# Parameter constraints +MIN_TEMPERATURE: Final = 0.0 +MAX_TEMPERATURE: Final = 2.0 +MIN_MAX_TOKENS: Final = 1 +MAX_MAX_TOKENS: Final = 4096 +MIN_REQUEST_INTERVAL: Final = 0.1 -# Attributes -ATTR_QUESTION = "question" -ATTR_RESPONSE = "response" -ATTR_LAST_UPDATED = "last_updated" +# Service names +SERVICE_ASK_QUESTION: Final = "ask_question" +SERVICE_CLEAR_HISTORY: Final = "clear_history" +SERVICE_GET_HISTORY: Final = "get_history" +SERVICE_SET_SYSTEM_PROMPT: Final = "set_system_prompt" + +# Service descriptions +SERVICE_ASK_QUESTION_DESCRIPTION: Final = "Ask a question to the AI model" +SERVICE_CLEAR_HISTORY_DESCRIPTION: Final = "Clear conversation history" +SERVICE_GET_HISTORY_DESCRIPTION: Final = "Get conversation history" +SERVICE_SET_SYSTEM_PROMPT_DESCRIPTION: Final = "Set system prompt for AI model" + +# Attribute keys +ATTR_QUESTION: Final = "question" +ATTR_RESPONSE: Final = "response" +ATTR_LAST_UPDATED: Final = "last_updated" + +# Error messages +ERROR_INVALID_API_KEY: Final = "invalid_api_key" +ERROR_CANNOT_CONNECT: Final = "cannot_connect" +ERROR_UNKNOWN: Final = "unknown_error" + +# Configuration descriptions +CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses" +CONF_TEMPERATURE_DESCRIPTION: Final = "Temperature for response generation (0-2)" +CONF_MAX_TOKENS_DESCRIPTION: Final = "Maximum tokens in response (1-4096)" +CONF_API_ENDPOINT_DESCRIPTION: Final = "API endpoint URL" +CONF_REQUEST_INTERVAL_DESCRIPTION: Final = "Minimum time between API requests (seconds)" + +# Entity attributes +ENTITY_NAME: Final = "HA Text AI" +ENTITY_ICON: Final = "mdi:robot" diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py index a79e9f1..9c360ec 100644 --- a/custom_components/ha_text_ai/coordinator.py +++ b/custom_components/ha_text_ai/coordinator.py @@ -1,21 +1,51 @@ +"""The HA Text AI integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from .const import DOMAIN, PLATFORMS +from .coordinator import HATextAICoordinator + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up HA Text AI from a config entry.""" + try: + coordinator = HATextAICoordinator( + hass, + api_key=entry.data["api_key"], + endpoint=entry.data.get("api_endpoint", "https://api.openai.com/v1"), + model=entry.data.get("model", "gpt-3.5-turbo"), + temperature=entry.data.get("temperature", 0.7), + max_tokens=entry.data.get("max_tokens", 1000), + request_interval=entry.data.get("request_interval", 1.0), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + return await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + except Exception as ex: + raise ConfigEntryNotReady(f"Failed to setup entry: {str(ex)}") from ex + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + """Data coordinator for HA text AI.""" import asyncio import logging from datetime import timedelta -from typing import Any, Dict +from typing import Any, Dict, Optional import openai from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import ( - DOMAIN, - DEFAULT_REQUEST_INTERVAL, - CONF_MODEL, - CONF_TEMPERATURE, - CONF_MAX_TOKENS, -) +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -40,18 +70,26 @@ class HATextAICoordinator(DataUpdateCoordinator): update_interval=timedelta(seconds=request_interval), ) + if not api_key: + raise ValueError("API key is required") + if not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2: + raise ValueError("Temperature must be between 0 and 2") + if not isinstance(max_tokens, int) or max_tokens < 1: + raise ValueError("Max tokens must be a positive integer") + self.api_key = api_key - self.endpoint = endpoint - self.model = model - self.temperature = temperature - self.max_tokens = max_tokens + self.endpoint = endpoint or "https://api.openai.com/v1" + self.model = model or "gpt-3.5-turbo" + self.temperature = float(temperature) + self.max_tokens = int(max_tokens) self._question_queue = asyncio.Queue() self._responses: Dict[str, Any] = {} self.system_prompt: Optional[str] = None - openai.api_key = self.api_key - if endpoint != "https://api.openai.com/v1": - openai.api_base = endpoint + self.client = openai.OpenAI( + api_key=self.api_key, + base_url=self.endpoint + ) async def _async_update_data(self) -> Dict[str, Any]: """Update data via OpenAI API.""" @@ -63,15 +101,14 @@ class HATextAICoordinator(DataUpdateCoordinator): response_content = await self.hass.async_add_executor_job( self._make_api_call, question ) - response = { + self._responses[question] = { "question": question, "response": response_content } - self._responses[question] = response - _LOGGER.debug(f"Response from API: {response}") + _LOGGER.debug("Response from API: %s", response_content) return self._responses - except openai.error.AuthenticationError as err: + except openai.AuthenticationError as err: raise ConfigEntryAuthFailed from err except Exception as err: _LOGGER.error("Error communicating with API: %s", err) @@ -80,9 +117,12 @@ class HATextAICoordinator(DataUpdateCoordinator): def _make_api_call(self, question: str) -> str: """Make API call to OpenAI.""" try: - messages = [{"role": "system", "content": self.system_prompt}] if self.system_prompt else [] + messages = [] + if self.system_prompt: + messages.append({"role": "system", "content": self.system_prompt}) messages.append({"role": "user", "content": question}) - completion = openai.chat.completions.create( + + completion = self.client.chat.completions.create( model=self.model, messages=messages, temperature=self.temperature, @@ -92,23 +132,3 @@ class HATextAICoordinator(DataUpdateCoordinator): except Exception as err: _LOGGER.error("Error in API call: %s", err) raise - - async def async_ask_question(self, question: str) -> None: - """Add question to queue.""" - await self._question_queue.put(question) - _LOGGER.debug(f"Question added to queue: {question}") - await self.async_refresh() - - def clear_history(self) -> None: - """Clear the stored question and response history.""" - self._responses.clear() - _LOGGER.info("History cleared.") - - def get_history(self, limit: int = 10) -> Dict[str, Any]: - """Get the history of questions and responses.""" - return {"history": list(self._responses.values())[-limit:]} - - def set_system_prompt(self, prompt: str) -> None: - """Set a system prompt that will be used for all future questions.""" - self.system_prompt = prompt - _LOGGER.info(f"System prompt set: {prompt}") diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index 5cc456f..8b721a5 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -1,15 +1,14 @@ { "domain": "ha_text_ai", - "name": "HA text AI", + "name": "HA Text AI", "config_flow": true, - "documentation": "https://github.com/smkrv/ha-text-ai", + "documentation": "https://github.com/smkrv/ha-text-ai/wiki", "issue_tracker": "https://github.com/smkrv/ha-text-ai/issues", "requirements": ["openai>=1.0.0"], "ssdp": [], "zeroconf": [], - "homekit": {}, "dependencies": [], - "codeowners": ["@smkrv"], - "version": "1.0.1b", - "iot_class": "cloud_polling" + "version": "1.0.1c", + "iot_class": "cloud_polling", + "codeowners": ["@smkrv"] } diff --git a/custom_components/ha_text_ai/sensor.py b/custom_components/ha_text_ai/sensor.py index 3ac6a87..d1715f8 100644 --- a/custom_components/ha_text_ai/sensor.py +++ b/custom_components/ha_text_ai/sensor.py @@ -1,7 +1,13 @@ """Sensor platform for HA text AI.""" +from datetime import datetime +import logging from typing import Any, Callable, Dict, Optional -from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.components.sensor import ( + SensorEntity, + SensorStateClass, + SensorDeviceClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -11,6 +17,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, ATTR_QUESTION, ATTR_RESPONSE, ATTR_LAST_UPDATED from .coordinator import HATextAICoordinator +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -23,6 +31,11 @@ async def async_setup_entry( class HATextAISensor(CoordinatorEntity, SensorEntity): """HA text AI Sensor.""" + _attr_has_entity_name = True + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_icon = "mdi:robot" + def __init__( self, coordinator: HATextAICoordinator, @@ -32,27 +45,50 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): super().__init__(coordinator) self._config_entry = config_entry self._attr_unique_id = f"{config_entry.entry_id}" - self._attr_name = "HA text AI" - self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_name = "Last Response" @property def state(self) -> StateType: """Return the state of the sensor.""" - if self.coordinator.data: - return "Ready" # Assuming "Ready" is a valid state, you might want to return something meaningful, like the last response time. - return "Not Ready" + if not self.coordinator.data: + return None + return self.coordinator.last_update_success_time @property def extra_state_attributes(self) -> Optional[Dict[str, Any]]: """Return entity specific state attributes.""" if not self.coordinator.data: return None - keys = list(self.coordinator.data.keys()) - values = list(self.coordinator.data.values()) - last_question = keys[-1] - last_response = values[-1] - return { - ATTR_QUESTION: last_question, - ATTR_RESPONSE: last_response, - ATTR_LAST_UPDATED: self.coordinator.last_update_success_time, - } + + try: + + history = list(self.coordinator.data.items()) + if not history: + return None + + last_question, last_data = history[-1] + + + if isinstance(last_data, dict): + last_response = last_data.get("response", "") + else: + last_response = str(last_data) + + return { + ATTR_QUESTION: last_question, + ATTR_RESPONSE: last_response, + ATTR_LAST_UPDATED: self.coordinator.last_update_success_time, + } + except (IndexError, KeyError, AttributeError) as err: + _LOGGER.warning("Error getting attributes: %s", err) + return None + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success + + @property + def should_poll(self) -> bool: + """No need to poll. Coordinator notifies entity of updates.""" + return False diff --git a/custom_components/ha_text_ai/services.yaml b/custom_components/ha_text_ai/services.yaml index 507b75c..2fa11b9 100644 --- a/custom_components/ha_text_ai/services.yaml +++ b/custom_components/ha_text_ai/services.yaml @@ -1,63 +1,76 @@ ask_question: name: Ask Question - description: Send a question to the AI and get a response + description: Send a question to the AI model and receive a detailed response fields: question: name: Question - description: The question or prompt to send to the AI + description: Your question or prompt for the AI assistant required: true - example: "What is the weather like today?" + example: "What automations would you recommend for a smart kitchen?" selector: text: multiline: true + type: text model: name: Model - description: Override the default model for this question (optional) + description: Select an AI model to use (optional, overrides default setting) required: false - example: "gpt-4" + example: "gpt-3.5-turbo" default: "gpt-3.5-turbo" selector: select: options: - - "gpt-3.5-turbo" - - "gpt-4" - - "gpt-4-32k" + - label: "GPT-3.5 Turbo" + value: "gpt-3.5-turbo" + icon: "mdi:rocket-launch" + - label: "GPT-4" + value: "gpt-4" + icon: "mdi:brain" + - label: "GPT-4 32K" + value: "gpt-4-32k" + icon: "mdi:brain-circuit" temperature: name: Temperature - description: Control randomness in the response (0.0-1.0, lower is more focused) + description: >- + Controls response creativity (0-2): + Lower values (0-0.7) for focused, consistent responses + Higher values (0.7-2.0) for more creative, varied responses required: false default: 0.7 selector: number: min: 0.0 - max: 1.0 + max: 2.0 step: 0.1 + mode: slider + unit_of_measurement: "" max_tokens: name: Max Tokens - description: Maximum length of the response + description: Maximum length of the response (longer responses use more tokens) required: false default: 1000 selector: number: min: 1 - max: 4000 + max: 4096 step: 1 + mode: box clear_history: name: Clear History - description: Clear the stored question and response history + description: Delete all stored questions and responses from the conversation history fields: {} get_history: name: Get History - description: Get the history of questions and responses + description: Retrieve recent conversation history between you and the AI fields: limit: name: Limit - description: Maximum number of history items to return + description: Number of most recent conversations to return required: false default: 10 selector: @@ -65,16 +78,26 @@ get_history: min: 1 max: 100 step: 1 + mode: box set_system_prompt: name: Set System Prompt - description: Set a system prompt that will be used for all future questions + description: >- + Configure the AI's behavior by setting a system prompt that will be used + for all future conversations until changed fields: prompt: name: System Prompt - description: The system prompt to set + description: >- + Instructions that define how the AI should behave and respond. + This affects all future conversations. required: true - example: "You are a helpful assistant specializing in home automation" + example: >- + You are a home automation expert assistant. Provide practical advice + focused on smart home technology and automation. Use clear, concise + language and include specific product recommendations when relevant. selector: text: multiline: true + type: text + rows: 4 diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index 8ff38f1..5087166 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -3,34 +3,76 @@ "step": { "user": { "title": "Set up HA text AI", - "description": "Set up your OpenAI integration", + "description": "Configure your OpenAI integration for smart home interactions", "data": { - "api_key": "API Key", - "model": "Model", - "temperature": "Temperature", - "max_tokens": "Max Tokens", - "api_endpoint": "API Endpoint", - "request_interval": "Request Interval (seconds)" + "api_key": { + "name": "API Key", + "description": "Your OpenAI API key (starts with 'sk-')" + }, + "model": { + "name": "AI Model", + "description": "Select the AI model to use (e.g., gpt-3.5-turbo)" + }, + "temperature": { + "name": "Temperature", + "description": "Response creativity (0-2): lower for focused, higher for creative responses" + }, + "max_tokens": { + "name": "Max Tokens", + "description": "Maximum response length (1-4096 tokens)" + }, + "api_endpoint": { + "name": "API Endpoint", + "description": "OpenAI API endpoint URL (leave default if unsure)" + }, + "request_interval": { + "name": "Request Interval", + "description": "Minimum time between API requests in seconds (0.1 or higher)" + } } } }, "error": { - "auth": "API key is invalid.", - "cannot_connect": "Failed to connect to API.", - "unknown": "Unexpected error occurred." + "invalid_auth": "Invalid API key. Please check your OpenAI API key and try again.", + "cannot_connect": "Failed to connect to API. Please check your internet connection and API endpoint.", + "unknown": "Unexpected error occurred. Please check the logs for more details.", + "already_exists": "This API key is already configured in another integration." }, "abort": { - "already_configured": "Device is already configured" + "already_configured": "This OpenAI integration is already configured", + "auth_failed": "Authentication failed. Please verify your API key." } }, "options": { "step": { "init": { "title": "HA text AI Options", + "description": "Adjust your OpenAI integration settings", "data": { - "temperature": "Temperature", - "max_tokens": "Max Tokens", - "request_interval": "Request Interval (seconds)" + "temperature": { + "name": "Temperature", + "description": "Controls response creativity (0-2): lower values for focused responses, higher for more creative ones" + }, + "max_tokens": { + "name": "Max Tokens", + "description": "Maximum length of responses (1-4096 tokens)" + }, + "request_interval": { + "name": "Request Interval", + "description": "Minimum time between API requests in seconds (0.1 or higher)" + } + } + } + } + }, + "entity": { + "sensor": { + "last_response": { + "name": "Last Response", + "state_attributes": { + "question": "Last Question", + "response": "AI Response", + "last_updated": "Last Updated" } } } diff --git a/ha_text_ai.zip b/ha-text-ai.zip similarity index 82% rename from ha_text_ai.zip rename to ha-text-ai.zip index c7825ad..c255fd5 100644 Binary files a/ha_text_ai.zip and b/ha-text-ai.zip differ diff --git a/hacs.json b/hacs.json index 65a9d37..34819b7 100644 --- a/hacs.json +++ b/hacs.json @@ -2,7 +2,7 @@ "name": "HA text AI", "render_readme": true, "domains": ["sensor"], - "homeassistant": "2024.11.2", + "homeassistant": "2024.11.0", "icon": "mdi:brain", "version": "1.0.1b", "documentation": "https://github.com/smkrv/ha-text-ai"