mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-22 07:03:58 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d899144149 | ||
|
|
5d49b4a40b | ||
|
|
1a84727cf1 | ||
|
|
d3c7e25202 | ||
|
|
5e82b9669c | ||
|
|
103f9d59e9 | ||
|
|
6ad67a5acf | ||
|
|
12e5778a1c | ||
|
|
5c85cba606 | ||
|
|
63d28e12d6 | ||
|
|
605c73c6c4 | ||
|
|
9d54f88520 |
@@ -2,13 +2,42 @@ 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
|
||||
|
||||
- name: ℹ️ Print hassfest version
|
||||
if: always()
|
||||
run: |
|
||||
echo "Hassfest version: $(hassfest --version)"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -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
|
||||
@@ -1,145 +1,165 @@
|
||||
# 🤖 HA text AI Integration for Home Assistant
|
||||
# 🤖 HA Text AI for Home Assistant
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||

|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
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
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📋 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
|
||||
|
||||
<details>
|
||||
<summary>Manual Installation Steps</summary>
|
||||
|
||||
```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
|
||||
```
|
||||
</details>
|
||||
|
||||
## ⚙️ 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
|
||||
|
||||
<details>
|
||||
<summary>Common Issues and Solutions</summary>
|
||||
### 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
|
||||
</details>
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[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
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
@@ -23,100 +23,101 @@ from .const import (
|
||||
CONF_API_ENDPOINT,
|
||||
CONF_REQUEST_INTERVAL,
|
||||
)
|
||||
from .coordinator import HATextAICoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def get_coordinator():
|
||||
"""Get coordinator class with lazy import to avoid circular deps."""
|
||||
from .coordinator import HATextAICoordinator
|
||||
return HATextAICoordinator
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): cv.string,
|
||||
vol.Optional(CONF_MODEL, default="gpt-3.5-turbo"): cv.string,
|
||||
vol.Optional(CONF_TEMPERATURE, default=0.7): vol.Coerce(float),
|
||||
vol.Optional(CONF_MAX_TOKENS, default=1000): vol.Coerce(int),
|
||||
vol.Optional(CONF_REQUEST_INTERVAL, default=1.0): vol.Coerce(float),
|
||||
vol.Optional(CONF_API_ENDPOINT): cv.string,
|
||||
}
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
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")
|
||||
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"]
|
||||
|
||||
coordinator = next(iter(hass.data[DOMAIN].values()))
|
||||
|
||||
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()))
|
||||
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)}")
|
||||
coordinator = next(iter(hass.data[DOMAIN].values()))
|
||||
coordinator.system_prompt = call.data["prompt"]
|
||||
|
||||
# Register services
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ASK_QUESTION,
|
||||
@@ -164,6 +165,7 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up HA text AI from a config entry."""
|
||||
HATextAICoordinator = get_coordinator()
|
||||
try:
|
||||
coordinator = HATextAICoordinator(
|
||||
hass,
|
||||
@@ -176,32 +178,32 @@ 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)
|
||||
if entry.entry_id not in hass.data.get(DOMAIN, {}):
|
||||
return True
|
||||
|
||||
# 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
|
||||
]:
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
# Remove services only if this was the last instance
|
||||
if not hass.data[DOMAIN]:
|
||||
services = [
|
||||
SERVICE_ASK_QUESTION,
|
||||
SERVICE_CLEAR_HISTORY,
|
||||
SERVICE_GET_HISTORY,
|
||||
SERVICE_SET_SYSTEM_PROMPT
|
||||
]
|
||||
for service in services:
|
||||
if DOMAIN in hass.services.async_services() and \
|
||||
service in hass.services.async_services()[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
|
||||
|
||||
@@ -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,135 @@ from .const import (
|
||||
DEFAULT_REQUEST_INTERVAL,
|
||||
)
|
||||
|
||||
class HATextAIConfigFlow(config_entries.ConfigFlow):
|
||||
import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
# Create OpenAI client
|
||||
client = openai.OpenAI(
|
||||
api_key=user_input[CONF_API_KEY],
|
||||
base_url=user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT)
|
||||
)
|
||||
|
||||
# Verify API connection and model availability
|
||||
models = await self.hass.async_add_executor_job(client.models.list)
|
||||
model_ids = [model.id for model in models.data]
|
||||
|
||||
if user_input[CONF_MODEL] not in model_ids:
|
||||
_LOGGER.warning(
|
||||
"Selected model %s not found in available models: %s",
|
||||
user_input[CONF_MODEL],
|
||||
", ".join(model_ids)
|
||||
)
|
||||
errors["base"] = "invalid_model"
|
||||
else:
|
||||
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 as err:
|
||||
_LOGGER.error("Authentication failed: %s", str(err))
|
||||
errors["base"] = "invalid_auth"
|
||||
except openai.APIError as err:
|
||||
_LOGGER.error("API connection failed: %s", str(err))
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected error: %s", str(err))
|
||||
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,
|
||||
description_placeholders={
|
||||
"default_model": DEFAULT_MODEL,
|
||||
"default_endpoint": DEFAULT_API_ENDPOINT,
|
||||
}
|
||||
)
|
||||
|
||||
@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={"suggested_value": DEFAULT_TEMPERATURE},
|
||||
): 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={"suggested_value": DEFAULT_MAX_TOKENS},
|
||||
): 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={"suggested_value": DEFAULT_REQUEST_INTERVAL},
|
||||
): 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,
|
||||
)
|
||||
|
||||
@@ -1,30 +1,92 @@
|
||||
"""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"
|
||||
ATTR_MODEL: Final = "model"
|
||||
ATTR_TEMPERATURE: Final = "temperature"
|
||||
ATTR_MAX_TOKENS: Final = "max_tokens"
|
||||
ATTR_TOTAL_RESPONSES: Final = "total_responses"
|
||||
ATTR_SYSTEM_PROMPT: Final = "system_prompt"
|
||||
ATTR_RESPONSE_TIME: Final = "response_time"
|
||||
|
||||
# Error messages
|
||||
ERROR_INVALID_API_KEY: Final = "invalid_api_key"
|
||||
ERROR_CANNOT_CONNECT: Final = "cannot_connect"
|
||||
ERROR_UNKNOWN: Final = "unknown_error"
|
||||
ERROR_INVALID_MODEL: Final = "invalid_model"
|
||||
ERROR_RATE_LIMIT: Final = "rate_limit_exceeded"
|
||||
ERROR_CONTEXT_LENGTH: Final = "context_length_exceeded"
|
||||
ERROR_API_ERROR: Final = "api_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 descriptions
|
||||
ATTR_QUESTION_DESCRIPTION: Final = "Last question asked"
|
||||
ATTR_RESPONSE_DESCRIPTION: Final = "Last response received"
|
||||
ATTR_LAST_UPDATED_DESCRIPTION: Final = "Time of last update"
|
||||
ATTR_MODEL_DESCRIPTION: Final = "Current AI model in use"
|
||||
ATTR_TEMPERATURE_DESCRIPTION: Final = "Current temperature setting"
|
||||
ATTR_MAX_TOKENS_DESCRIPTION: Final = "Current max tokens setting"
|
||||
ATTR_TOTAL_RESPONSES_DESCRIPTION: Final = "Total number of responses"
|
||||
ATTR_SYSTEM_PROMPT_DESCRIPTION: Final = "Current system prompt"
|
||||
ATTR_RESPONSE_TIME_DESCRIPTION: Final = "Time taken for last response"
|
||||
|
||||
# Entity attributes
|
||||
ENTITY_NAME: Final = "HA Text AI"
|
||||
ENTITY_ICON: Final = "mdi:robot"
|
||||
|
||||
# Translation keys
|
||||
TRANSLATION_KEY_CONFIG: Final = "config"
|
||||
TRANSLATION_KEY_OPTIONS: Final = "options"
|
||||
TRANSLATION_KEY_ERROR: Final = "error"
|
||||
|
||||
# State attributes
|
||||
STATE_READY: Final = "ready"
|
||||
STATE_PROCESSING: Final = "processing"
|
||||
STATE_ERROR: Final = "error"
|
||||
|
||||
@@ -1,21 +1,50 @@
|
||||
"""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
|
||||
|
||||
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 +69,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 +100,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 +116,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 +131,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}")
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
{
|
||||
"domain": "ha-text-ai",
|
||||
"name": "HA text AI",
|
||||
"domain": "ha_text_ai",
|
||||
"name": "HA Text AI",
|
||||
"codeowners": ["@smkrv"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/smkrv/ha-text-ai",
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/smkrv/ha-text-ai/wiki",
|
||||
"iot_class": "cloud_polling",
|
||||
"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.3",
|
||||
"zeroconf": []
|
||||
}
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
"""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
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN, ATTR_QUESTION, ATTR_RESPONSE, ATTR_LAST_UPDATED
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
ATTR_QUESTION,
|
||||
ATTR_RESPONSE,
|
||||
ATTR_LAST_UPDATED,
|
||||
ATTR_MODEL,
|
||||
ATTR_TEMPERATURE,
|
||||
ATTR_MAX_TOKENS,
|
||||
ATTR_TOTAL_RESPONSES,
|
||||
)
|
||||
from .coordinator import HATextAICoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
@@ -23,6 +41,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 +55,84 @@ 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"
|
||||
self._attr_suggested_display_precision = 0
|
||||
|
||||
@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 or not self.coordinator.last_update_success_time:
|
||||
return None
|
||||
|
||||
# Convert to local time
|
||||
if isinstance(self.coordinator.last_update_success_time, datetime):
|
||||
return dt_util.as_local(self.coordinator.last_update_success_time)
|
||||
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,
|
||||
}
|
||||
return {
|
||||
ATTR_TOTAL_RESPONSES: 0,
|
||||
ATTR_MODEL: self.coordinator.model,
|
||||
ATTR_TEMPERATURE: self.coordinator.temperature,
|
||||
ATTR_MAX_TOKENS: self.coordinator.max_tokens,
|
||||
}
|
||||
|
||||
try:
|
||||
history = list(self.coordinator.data.items())
|
||||
if not history:
|
||||
return {
|
||||
ATTR_TOTAL_RESPONSES: 0,
|
||||
ATTR_MODEL: self.coordinator.model,
|
||||
ATTR_TEMPERATURE: self.coordinator.temperature,
|
||||
ATTR_MAX_TOKENS: self.coordinator.max_tokens,
|
||||
}
|
||||
|
||||
last_question, last_data = history[-1]
|
||||
|
||||
# Handle different response formats
|
||||
if isinstance(last_data, dict):
|
||||
last_response = last_data.get("response", "")
|
||||
last_updated = last_data.get("timestamp", self.coordinator.last_update_success_time)
|
||||
else:
|
||||
last_response = str(last_data)
|
||||
last_updated = self.coordinator.last_update_success_time
|
||||
|
||||
# Convert timestamp to local time if needed
|
||||
if isinstance(last_updated, datetime):
|
||||
last_updated = dt_util.as_local(last_updated)
|
||||
|
||||
return {
|
||||
ATTR_QUESTION: last_question,
|
||||
ATTR_RESPONSE: last_response,
|
||||
ATTR_LAST_UPDATED: last_updated,
|
||||
ATTR_TOTAL_RESPONSES: len(history),
|
||||
ATTR_MODEL: self.coordinator.model,
|
||||
ATTR_TEMPERATURE: self.coordinator.temperature,
|
||||
ATTR_MAX_TOKENS: self.coordinator.max_tokens,
|
||||
}
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error getting attributes: %s", err, exc_info=True)
|
||||
return {
|
||||
ATTR_TOTAL_RESPONSES: 0,
|
||||
ATTR_MODEL: self.coordinator.model,
|
||||
ATTR_TEMPERATURE: self.coordinator.temperature,
|
||||
ATTR_MAX_TOKENS: self.coordinator.max_tokens,
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._handle_coordinator_update()
|
||||
|
||||
@@ -1,63 +1,94 @@
|
||||
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.
|
||||
The response will be stored in the conversation history and can be retrieved later.
|
||||
fields:
|
||||
question:
|
||||
name: Question
|
||||
description: The question or prompt to send to the AI
|
||||
description: >-
|
||||
Your question or prompt for the AI assistant. Be specific and clear for better results.
|
||||
You can ask about home automation, technical advice, or general questions.
|
||||
required: true
|
||||
example: "What is the weather like today?"
|
||||
example: |
|
||||
What automations would you recommend for a smart kitchen?
|
||||
Consider energy efficiency and convenience.
|
||||
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).
|
||||
Different models have different capabilities and token limits.
|
||||
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 (Fast & Efficient)"
|
||||
value: "gpt-3.5-turbo"
|
||||
- label: "GPT-4 (Most Capable)"
|
||||
value: "gpt-4"
|
||||
- label: "GPT-4 32K (Extended Context)"
|
||||
value: "gpt-4-32k"
|
||||
mode: dropdown
|
||||
|
||||
temperature:
|
||||
name: Temperature
|
||||
description: Control randomness in the response (0.0-1.0, lower is more focused)
|
||||
description: >-
|
||||
Controls response creativity (0-2):
|
||||
0.0-0.3: Focused, consistent responses
|
||||
0.4-0.7: Balanced responses
|
||||
0.8-2.0: 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. Higher values allow longer responses but use more API tokens.
|
||||
Recommended ranges:
|
||||
- Short responses: 256-512
|
||||
- Medium responses: 512-1024
|
||||
- Long responses: 1024-4096
|
||||
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.
|
||||
This action cannot be undone.
|
||||
fields: {}
|
||||
|
||||
get_history:
|
||||
name: Get History
|
||||
description: Get the history of questions and responses
|
||||
description: >-
|
||||
Retrieve recent conversation history, including questions, responses, and timestamps.
|
||||
Results are ordered from newest to oldest.
|
||||
fields:
|
||||
limit:
|
||||
name: Limit
|
||||
description: Maximum number of history items to return
|
||||
description: >-
|
||||
Number of most recent conversations to return.
|
||||
Higher values return more history but may take longer to process.
|
||||
required: false
|
||||
default: 10
|
||||
selector:
|
||||
@@ -65,16 +96,28 @@ 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.
|
||||
This affects how the AI interprets and responds to all future questions.
|
||||
fields:
|
||||
prompt:
|
||||
name: System Prompt
|
||||
description: The system prompt to set
|
||||
description: >-
|
||||
Instructions that define how the AI should behave and respond.
|
||||
Be specific about the desired expertise, tone, and format of responses.
|
||||
required: true
|
||||
example: "You are a helpful assistant specializing in home automation"
|
||||
example: |
|
||||
You are a home automation expert assistant. Focus on:
|
||||
1. Practical and efficient solutions
|
||||
2. Energy-saving recommendations
|
||||
3. Integration with popular smart home platforms
|
||||
4. Security and privacy considerations
|
||||
Provide detailed but concise responses with clear steps when applicable.
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
type: text
|
||||
|
||||
@@ -3,36 +3,140 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up HA text AI",
|
||||
"description": "Set up your OpenAI integration",
|
||||
"description": "Configure your OpenAI integration for smart home interactions. You'll need an OpenAI API key to proceed.",
|
||||
"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": "OpenAI API Key",
|
||||
"description": "Your OpenAI API key from platform.openai.com"
|
||||
},
|
||||
"model": {
|
||||
"name": "AI Model",
|
||||
"description": "Select the AI model to use. GPT-3.5-Turbo is recommended for most uses."
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature",
|
||||
"description": "Controls response creativity (0-2). Lower values for focused responses, higher for creative ones."
|
||||
},
|
||||
"max_tokens": {
|
||||
"name": "Max Tokens",
|
||||
"description": "Maximum length of responses. Higher values allow longer responses but use more API tokens."
|
||||
},
|
||||
"api_endpoint": {
|
||||
"name": "API Endpoint",
|
||||
"description": "OpenAI API endpoint URL. Leave default unless using a custom endpoint."
|
||||
},
|
||||
"request_interval": {
|
||||
"name": "Request Interval",
|
||||
"description": "Minimum time between API requests in seconds. Increase if hitting rate limits."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"invalid_model": "Selected model is not available. Please choose a different model.",
|
||||
"rate_limit": "API rate limit exceeded. Please try again later or increase the request interval.",
|
||||
"context_length": "Input too long for selected model. Try reducing max tokens or using a model with larger context.",
|
||||
"api_error": "OpenAI API error. Please check the logs for details."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured"
|
||||
"already_configured": "This OpenAI integration is already configured",
|
||||
"auth_failed": "Authentication failed. Please verify your API key.",
|
||||
"invalid_endpoint": "Invalid API endpoint URL provided"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "HA text AI Options",
|
||||
"description": "Adjust your OpenAI integration settings. Changes will apply to future requests.",
|
||||
"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 creative ones."
|
||||
},
|
||||
"max_tokens": {
|
||||
"name": "Max Tokens",
|
||||
"description": "Maximum length of responses. Higher values allow longer responses but use more API tokens."
|
||||
},
|
||||
"request_interval": {
|
||||
"name": "Request Interval",
|
||||
"description": "Minimum time between API requests in seconds. Increase if hitting rate limits."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_response": {
|
||||
"name": "Last Response",
|
||||
"state_attributes": {
|
||||
"last_updated": {
|
||||
"name": "Last Updated",
|
||||
"description": "Time of the last AI response"
|
||||
},
|
||||
"question": {
|
||||
"name": "Last Question",
|
||||
"description": "Most recent question asked"
|
||||
},
|
||||
"response": {
|
||||
"name": "AI Response",
|
||||
"description": "Latest response from the AI"
|
||||
},
|
||||
"model": {
|
||||
"name": "Current Model",
|
||||
"description": "AI model currently in use"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature Setting",
|
||||
"description": "Current temperature parameter"
|
||||
},
|
||||
"max_tokens": {
|
||||
"name": "Max Tokens Setting",
|
||||
"description": "Current maximum tokens limit"
|
||||
},
|
||||
"total_responses": {
|
||||
"name": "Total Responses",
|
||||
"description": "Number of responses since last reset"
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "System Prompt",
|
||||
"description": "Current system instructions for the AI"
|
||||
},
|
||||
"response_time": {
|
||||
"name": "Response Time",
|
||||
"description": "Time taken to generate last response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"ask_question": {
|
||||
"name": "Ask Question",
|
||||
"description": "Send a question to the AI model",
|
||||
"fields": {
|
||||
"question": {
|
||||
"name": "Question",
|
||||
"description": "Your question for the AI"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clear_history": {
|
||||
"name": "Clear History",
|
||||
"description": "Clear conversation history"
|
||||
},
|
||||
"get_history": {
|
||||
"name": "Get History",
|
||||
"description": "Retrieve conversation history"
|
||||
},
|
||||
"set_system_prompt": {
|
||||
"name": "Set System Prompt",
|
||||
"description": "Set AI behavior instructions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -2,6 +2,8 @@
|
||||
"name": "HA text AI",
|
||||
"render_readme": true,
|
||||
"domains": ["sensor"],
|
||||
"homeassistant": "2023.8.0",
|
||||
"icon": "mdi:brain"
|
||||
"homeassistant": "2024.11.0",
|
||||
"icon": "mdi:brain",
|
||||
"version": "1.0.3",
|
||||
"documentation": "https://github.com/smkrv/ha-text-ai"
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Common fixtures for tests."""
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@pytest.fixture
|
||||
def hass() -> HomeAssistant:
|
||||
"""Return a Home Assistant instance for testing."""
|
||||
return HomeAssistant()
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Tests for the HA text AI integration."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
|
||||
from custom_components.ha_text_ai.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> AsyncMock:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"custom_components.ha_text_ai.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
@pytest.fixture
|
||||
def mock_coordinator() -> AsyncMock:
|
||||
"""Override coordinator."""
|
||||
with patch(
|
||||
"custom_components.ha_text_ai.coordinator.HATextAICoordinator",
|
||||
return_value=AsyncMock(),
|
||||
) as mock_coordinator:
|
||||
yield mock_coordinator
|
||||
|
||||
async def test_async_setup(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
||||
"""Test the initial setup."""
|
||||
assert await async_setup_component(hass, DOMAIN, {
|
||||
DOMAIN: {
|
||||
"api_key": "test_key",
|
||||
"model": "gpt-3.5-turbo",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1000,
|
||||
"api_endpoint": "https://api.openai.com/v1",
|
||||
"request_interval": 1.0
|
||||
}
|
||||
})
|
||||
await hass.async_block_till_done()
|
||||
assert DOMAIN in hass.data
|
||||
|
||||
async def test_async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_coordinator: AsyncMock
|
||||
) -> None:
|
||||
"""Test setup entry."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"api_key": "test_key",
|
||||
"model": "gpt-3.5-turbo",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1000,
|
||||
"api_endpoint": "https://api.openai.com/v1",
|
||||
"request_interval": 1.0
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_coordinator.mock_calls) == 1
|
||||
assert DOMAIN in hass.data
|
||||
assert entry.entry_id in hass.data[DOMAIN]
|
||||
Reference in New Issue
Block a user