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
-
+
+

-
-
-[](https://github.com/hacs/integration)
-[](https://opensource.org/licenses/MIT)
+
+[](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"