Compare commits

...
2 Commits
Author SHA1 Message Date
SMKRV f1deaa2014 Release v1.0.4 2024-11-19 14:35:45 +03:00
SMKRV f4b0ce902e Release v1.0.3 2024-11-19 14:06:32 +03:00
12 changed files with 974 additions and 433 deletions
+166 -68
View File
@@ -7,154 +7,250 @@
![GitHub stars](https://img.shields.io/github/stars/smkrv/ha-text-ai.svg?style=social) ![GitHub stars](https://img.shields.io/github/stars/smkrv/ha-text-ai.svg?style=social)
![GitHub last commit](https://img.shields.io/github/last-commit/smkrv/ha-text-ai.svg?style=flat-square) ![GitHub last commit](https://img.shields.io/github/last-commit/smkrv/ha-text-ai.svg?style=flat-square)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=flat-square)](https://github.com/hacs/integration)
[![Community Forum](https://img.shields.io/badge/Community-Forum-blue.svg?style=flat-square)](https://community.home-assistant.io/t/ha-text-ai-integration)
</div> </div>
<p align="center"> <p align="center">
Transform your smart home experience with powerful AI assistance powered by OpenAI's GPT models Transform your smart home experience with powerful AI assistance powered by OpenAI's GPT models. Get intelligent responses, automate complex scenarios, and enhance your home automation with natural language processing.
</p> </p>
--- ---
## 🌟 Features ## 🌟 Features
- 🧠 **Advanced AI Integration**: Leverage OpenAI's powerful models (GPT-3.5, GPT-4) for smart home interactions - 🧠 **Advanced AI Integration**:
- 💬 **Natural Language Control**: Control your home and get information using everyday language - Support for latest GPT models
- 📝 **Conversation Memory**: Maintain context with conversation history tracking - Context-aware responses
-**Real-time Responses**: Get quick, contextual responses to your queries - Multi-turn conversations
- 🎯 **Customizable Behavior**: Fine-tune AI responses with adjustable parameters - 💬 **Natural Language Control**:
- 🔒 **Secure Integration**: Your API key and data are handled securely - Control devices using everyday language
- 🎨 **Flexible Configuration**: Easy setup with multiple configuration options - Get detailed explanations and recommendations
- 🔄 **Automation Ready**: Integrate AI responses into your automations - Natural conversation flow
- 📝 **Smart Memory Management**:
- Persistent conversation history
- Context-aware responses
- Customizable history limits
-**Performance Optimized**:
- Efficient token usage
- Rate limit handling
- Response caching
- 🎯 **Advanced Customization**:
- Adjustable response parameters
- Custom system prompts
- Model selection per request
- 🔒 **Enhanced Security**:
- Secure API key storage
- Rate limiting protection
- Error handling
- 🎨 **User Experience**:
- Intuitive configuration UI
- Detailed sensor attributes
- Rich service interface
- 🔄 **Automation Integration**:
- Event-driven responses
- Conditional logic support
- Template compatibility
## 📋 Prerequisites ## 📋 Prerequisites
- Home Assistant installation (Core, OS, Container, or Supervised) - Home Assistant 2023.8.0 or newer
- OpenAI API key ([Get one here](https://platform.openai.com/account/api-keys)) - OpenAI API key ([Get one here](https://platform.openai.com/account/api-keys))
- Python 3.9 or newer - Python 3.9 or newer
- Stable internet connection
## ⚡ Quick Start ## ⚡ Installation
### HACS Installation (Recommended)
1. Open HACS in Home Assistant
2. Click the "+" button
3. Search for "HA Text AI"
4. Click "Install"
5. Restart Home Assistant
### Manual Installation ### Manual Installation
1. Download the repository 1. Download the latest release
2. Copy `custom_components/ha_text_ai` to your `custom_components` directory 2. Extract and copy `custom_components/ha_text_ai` to your `custom_components` directory
3. Restart Home Assistant 3. Restart Home Assistant
4. Add configuration to `configuration.yaml`: 4. Add configuration via UI or YAML
## ⚙️ Configuration
### Via UI (Recommended)
1. Go to Settings → Devices & Services
2. Click "Add Integration"
3. Search for "HA Text AI"
4. Follow the configuration steps
### Via YAML
```yaml ```yaml
ha_text_ai: ha_text_ai:
api_key: !secret openai_api_key api_key: !secret openai_api_key
model: gpt-3.5-turbo
temperature: 0.7
max_tokens: 1000
request_interval: 1.0
api_endpoint: https://api.openai.com/v1 # optional
``` ```
## ⚙️ Configuration Options
| 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 |
## 🛠️ Available Services ## 🛠️ Available Services
### ask_question ### ask_question
Ask the AI assistant a question:
```yaml ```yaml
service: ha_text_ai.ask_question service: ha_text_ai.ask_question
data: data:
question: "What's the optimal temperature for sleeping?" question: "What's the optimal temperature for sleeping?"
model: "gpt-4" # optional model: "gpt-4o" # optional
temperature: 0.5 # optional temperature: 0.5 # optional
max_tokens: 500 # optional max_tokens: 500 # optional
``` ```
### set_system_prompt ### set_system_prompt
Configure AI behavior:
```yaml ```yaml
service: ha_text_ai.set_system_prompt service: ha_text_ai.set_system_prompt
data: data:
prompt: "You are a home automation expert focused on energy efficiency" prompt: |
You are a home automation expert focused on:
1. Energy efficiency
2. Comfort optimization
3. Security considerations
Provide practical, actionable advice.
``` ```
### clear_history ### clear_history
Reset conversation history:
```yaml ```yaml
service: ha_text_ai.clear_history service: ha_text_ai.clear_history
``` ```
### get_history ### get_history
Retrieve conversation history:
```yaml ```yaml
service: ha_text_ai.get_history service: ha_text_ai.get_history
data: data:
limit: 5 # optional limit: 5 # optional
``` ```
## 🔧 Practical Examples ## 🔧 Advanced Examples
### Smart Temperature Management ### Smart Energy Management
```yaml ```yaml
automation: automation:
alias: "AI Energy Optimization"
trigger: trigger:
platform: time_pattern platform: time_pattern
hours: "/1" hours: "/2"
action: action:
service: ha_text_ai.ask_question - service: ha_text_ai.ask_question
data: data:
question: > question: >
Current temperature is {{ states('sensor.living_room_temperature') }}°C. Current power usage: {{ states('sensor.total_power') }}W
Should I adjust the thermostat for optimal comfort and energy savings? Temperature: {{ states('sensor.indoor_temperature') }}°C
Time: {{ now().strftime('%H:%M') }}
Occupancy: {{ states('binary_sensor.occupancy') }}
Analyze current energy usage and suggest optimizations
considering comfort and efficiency.
temperature: 0.3
max_tokens: 200
- service: notify.mobile_app
data:
message: "{{ states.sensor.ha_text_ai.attributes.response }}"
``` ```
### Smart Lighting Assistant ### Contextual Lighting Control
```yaml ```yaml
automation: automation:
alias: "AI Lighting Assistant"
trigger: trigger:
platform: state platform: state
entity_id: binary_sensor.living_room_motion entity_id: binary_sensor.motion
to: 'on' variables:
condition: context: >
condition: template Time: {{ now().strftime('%H:%M') }}
value_template: "{{ states('sensor.illuminance') | float < 10 }}" Light Level: {{ states('sensor.illuminance') }}
Room: {{ trigger.to_state.attributes.room }}
Activity: {{ states('input_select.current_activity') }}
Weather: {{ states('weather.home') }}
action: action:
service: ha_text_ai.ask_question - service: ha_text_ai.ask_question
data: data:
question: > question: >
Motion detected in living room with low light levels. Based on this context:
What's the best lighting scene to set based on the time of day? {{ context }}
Suggest optimal lighting settings for current conditions.
model: gpt-3.5-turbo
temperature: 0.4
- service: scene.turn_on
data:
entity_id: >
{{ states.sensor.ha_text_ai.attributes.response | regex_findall('scene\.[a-z_]+') | first }}
``` ```
## ❗ Common Issues ## 📊 Performance Optimization
### API Rate Limits ### Token Usage
- Increase `request_interval` if hitting rate limits
- Consider upgrading your OpenAI plan
- Use caching for frequent queries
### High Token Usage
- Reduce `max_tokens` parameter
- Clear conversation history regularly
- Use focused system prompts - Use focused system prompts
- Implement response caching
- Clear history periodically
- Monitor token usage
### Connection Issues ### Response Time
- Check internet connectivity - Adjust request_interval
- Use faster models for simple queries
- Implement timeout handling
- Cache frequent responses
### Memory Management
- Set appropriate history limits
- Clear unused contexts
- Monitor memory usage
- Use efficient data structures
## ❗ Troubleshooting
### API Issues
- Verify API key validity - Verify API key validity
- Ensure endpoint accessibility - Check rate limits
- Monitor usage quotas
- Test endpoint accessibility
### Performance Issues
- Reduce max_tokens
- Increase request_interval
- Clear conversation history
- Check network connectivity
### Integration Issues
- Verify HA version compatibility
- Check component dependencies
- Review log files
- Update configuration
## 📘 FAQ
**Q: How can I reduce API costs?**
A: Use GPT-3.5-Turbo for most queries, implement caching, and optimize token usage.
**Q: Is my data secure?**
A: Yes, API keys are stored securely and data is transmitted via encrypted connections.
**Q: Can I use custom models?**
A: Yes, configure custom endpoints and models via configuration options.
## 🤝 Contributing ## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request. Contributions welcome! Please read our [Contributing Guide](CONTRIBUTING.md).
1. Fork the repository 1. Fork the repository
2. Create your feature branch (`git checkout -b feature/AmazingFeature`) 2. Create feature branch (`git checkout -b feature/Enhancement`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 3. Commit changes (`git commit -m 'Add Enhancement'`)
4. Push to the branch (`git push origin feature/AmazingFeature`) 4. Push branch (`git push origin feature/Enhancement`)
5. Open a Pull Request 5. Open Pull Request
## 📝 License ## 📝 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. MIT License - see [LICENSE](LICENSE) for details.
--- ---
@@ -162,4 +258,6 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
Made with ❤️ for the Home Assistant Community Made with ❤️ for the Home Assistant Community
[Report Bug](https://github.com/smkrv/ha-text-ai/issues) · [Request Feature](https://github.com/smkrv/ha-text-ai/issues)
</div> </div>
+60 -180
View File
@@ -1,209 +1,89 @@
"""The HA text AI integration.""" """The HA Text AI integration."""
import logging import logging
from typing import Any from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import HomeAssistantError, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client
from .const import ( from .const import DOMAIN, PLATFORMS
DOMAIN, from .coordinator import HATextAICoordinator
PLATFORMS,
SERVICE_ASK_QUESTION,
SERVICE_CLEAR_HISTORY,
SERVICE_GET_HISTORY,
SERVICE_SET_SYSTEM_PROMPT,
CONF_MODEL,
CONF_TEMPERATURE,
CONF_MAX_TOKENS,
CONF_API_ENDPOINT,
CONF_REQUEST_INTERVAL,
)
_LOGGER = logging.getLogger(__name__) _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: async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up the HA text AI component.""" """Set up the HA Text AI component."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
async def async_ask_question(call: ServiceCall) -> None:
"""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
}
try:
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"]
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)}") 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."""
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()
async def async_get_history(call: ServiceCall) -> dict[str, list]:
"""Handle the get_history service call."""
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": []}
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."""
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"]
hass.services.async_register(
DOMAIN,
SERVICE_ASK_QUESTION,
async_ask_question,
schema=vol.Schema({
vol.Required("question"): cv.string,
vol.Optional("model"): cv.string,
vol.Optional("temperature"): vol.All(
vol.Coerce(float), vol.Range(min=0, max=2)
),
vol.Optional("max_tokens"): vol.All(
vol.Coerce(int), vol.Range(min=1, max=4096)
),
})
)
hass.services.async_register(
DOMAIN,
SERVICE_CLEAR_HISTORY,
async_clear_history,
schema=vol.Schema({})
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_HISTORY,
async_get_history,
schema=vol.Schema({
vol.Optional("limit", default=10): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
})
)
hass.services.async_register(
DOMAIN,
SERVICE_SET_SYSTEM_PROMPT,
async_set_system_prompt,
schema=vol.Schema({
vol.Required("prompt"): cv.string,
})
)
return True return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HA text AI from a config entry.""" """Set up HA Text AI from a config entry."""
HATextAICoordinator = get_coordinator()
try: try:
session = aiohttp_client.async_get_clientsession(hass)
coordinator = HATextAICoordinator( coordinator = HATextAICoordinator(
hass, hass,
api_key=entry.data[CONF_API_KEY], api_key=entry.data[CONF_API_KEY],
endpoint=entry.data.get(CONF_API_ENDPOINT), endpoint=entry.data.get("api_endpoint", "https://api.openai.com/v1"),
model=entry.data.get(CONF_MODEL), model=entry.data.get("model", "gpt-3.5-turbo"),
temperature=entry.data.get(CONF_TEMPERATURE), temperature=entry.data.get("temperature", 0.7),
max_tokens=entry.data.get(CONF_MAX_TOKENS), max_tokens=entry.data.get("max_tokens", 1000),
request_interval=entry.data.get(CONF_REQUEST_INTERVAL), request_interval=entry.data.get("request_interval", 1.0),
session=session,
) )
await coordinator.async_config_entry_first_refresh() try:
await coordinator.async_config_entry_first_refresh()
except Exception as refresh_ex:
_LOGGER.error("Failed to refresh coordinator: %s", str(refresh_ex))
return False
if not coordinator.last_update_success:
_LOGGER.error("Failed to communicate with OpenAI API")
return False
hass.data[DOMAIN][entry.entry_id] = coordinator hass.data[DOMAIN][entry.entry_id] = coordinator
return await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) try:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except Exception as setup_ex:
_LOGGER.error("Failed to setup platforms: %s", str(setup_ex))
return False
_LOGGER.info(
"Successfully set up HA Text AI with model: %s",
entry.data.get("model", "gpt-3.5-turbo")
)
return True
except Exception as ex: except Exception as ex:
raise ConfigEntryNotReady(f"Failed to setup entry: {str(ex)}") from ex _LOGGER.exception("Unexpected error setting up entry: %s", str(ex))
return False
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if entry.entry_id not in hass.data.get(DOMAIN, {}): try:
return True if entry.entry_id not in hass.data.get(DOMAIN, {}):
return True
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) coordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.async_shutdown()
# Remove services only if this was the last instance return unload_ok
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.exception("Error unloading entry: %s", str(ex))
return False
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry."""
try:
await async_unload_entry(hass, entry)
await async_setup_entry(hass, entry)
except Exception as ex:
_LOGGER.exception("Error reloading entry: %s", str(ex))
+155 -34
View File
@@ -1,12 +1,19 @@
"""Config flow for HA text AI integration.""" """Config flow for HA text AI integration."""
from typing import Any, Dict, Optional from typing import Any, Dict, Optional, Tuple
import voluptuous as vol import voluptuous as vol
import ssl
import certifi
import asyncio
from async_timeout import timeout
import aiohttp
from urllib.parse import urlparse
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.core import callback from homeassistant.core import callback
import openai from openai import AsyncOpenAI
from openai import OpenAIError, APIError, APIConnectionError, AuthenticationError, RateLimitError
from .const import ( from .const import (
DOMAIN, DOMAIN,
@@ -31,18 +38,126 @@ STEP_USER_DATA_SCHEMA = vol.Schema({
vol.Optional( vol.Optional(
CONF_TEMPERATURE, CONF_TEMPERATURE,
default=DEFAULT_TEMPERATURE default=DEFAULT_TEMPERATURE
): vol.All(vol.Coerce(float), vol.Range(min=0, max=2)), ): vol.All(
vol.Coerce(float),
vol.Range(min=0, max=2),
msg="Temperature must be between 0 and 2"
),
vol.Optional( vol.Optional(
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
default=DEFAULT_MAX_TOKENS default=DEFAULT_MAX_TOKENS
): vol.All(vol.Coerce(int), vol.Range(min=1, max=4096)), ): vol.All(
vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): str, vol.Coerce(int),
vol.Range(min=1, max=4096),
msg="Max tokens must be between 1 and 4096"
),
vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): vol.All(
str,
vol.URL(),
msg="Must be a valid URL"
),
vol.Optional( vol.Optional(
CONF_REQUEST_INTERVAL, CONF_REQUEST_INTERVAL,
default=DEFAULT_REQUEST_INTERVAL default=DEFAULT_REQUEST_INTERVAL
): vol.All(vol.Coerce(float), vol.Range(min=0.1)), ): vol.All(
vol.Coerce(float),
vol.Range(min=0.1),
msg="Request interval must be at least 0.1 seconds"
),
}) })
async def validate_endpoint(endpoint: str) -> Tuple[bool, str]:
"""Validate API endpoint accessibility."""
try:
parsed_url = urlparse(endpoint)
if parsed_url.scheme not in ('http', 'https'):
return False, "invalid_endpoint_scheme"
ssl_context = ssl.create_default_context(cafile=certifi.where())
async with timeout(5):
async with aiohttp.ClientSession() as session:
async with session.get(endpoint, ssl=ssl_context) as response:
if response.status != 200:
return False, "endpoint_not_available"
return True, ""
except Exception as e:
_LOGGER.error("Error validating endpoint: %s", str(e))
return False, "endpoint_error"
async def validate_api_connection(
api_key: str,
endpoint: str,
model: str,
retry_count: int = 3,
retry_delay: float = 1.0
) -> Tuple[bool, str, list]:
"""Validate API connection with improved retry logic."""
ssl_context = ssl.create_default_context(cafile=certifi.where())
# Validate endpoint first
endpoint_valid, endpoint_error = await validate_endpoint(endpoint)
if not endpoint_valid:
return False, endpoint_error, []
for attempt in range(retry_count):
try:
async with timeout(10):
client = AsyncOpenAI(
api_key=api_key,
base_url=endpoint,
http_client=aiohttp.ClientSession(
connector=aiohttp.TCPConnector(
ssl=ssl_context,
enable_cleanup_closed=True
)
)
)
try:
models = await client.models.list()
model_ids = [model.id for model in models.data]
finally:
await client.http_client.close()
if model not in model_ids:
_LOGGER.warning(
"Model %s not found in available models: %s",
model,
", ".join(model_ids)
)
return False, "invalid_model", model_ids
return True, "", model_ids
except asyncio.TimeoutError:
_LOGGER.warning(
"Timeout during API validation (attempt %d/%d)",
attempt + 1,
retry_count
)
if attempt == retry_count - 1:
return False, "timeout", []
await asyncio.sleep(retry_delay)
except AuthenticationError as err:
_LOGGER.error("Authentication error: %s", str(err))
return False, "invalid_auth", []
except RateLimitError as err:
_LOGGER.error("Rate limit exceeded: %s", str(err))
return False, "rate_limit", []
except APIConnectionError as err:
_LOGGER.error("API connection error: %s", str(err))
return False, "cannot_connect", []
except APIError as err:
_LOGGER.error("API error: %s", str(err))
return False, "api_error", []
except Exception as err:
_LOGGER.exception("Unexpected error during validation: %s", str(err))
return False, "unknown", []
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for HA text AI.""" """Handle a config flow for HA text AI."""
@@ -57,24 +172,16 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
try: try:
# Create OpenAI client # Validate input data
client = openai.OpenAI( user_input = STEP_USER_DATA_SCHEMA(user_input)
api_key=user_input[CONF_API_KEY],
base_url=user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT) is_valid, error_code, available_models = await validate_api_connection(
user_input[CONF_API_KEY],
user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT),
user_input[CONF_MODEL]
) )
# Verify API connection and model availability if is_valid:
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]) await self.async_set_unique_id(user_input[CONF_API_KEY])
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
@@ -83,15 +190,17 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
data=user_input data=user_input
) )
except openai.AuthenticationError as err: errors["base"] = error_code
_LOGGER.error("Authentication failed: %s", str(err)) if error_code == "invalid_model":
errors["base"] = "invalid_auth" _LOGGER.warning(
except openai.APIError as err: "Selected model %s not found in available models: %s",
_LOGGER.error("API connection failed: %s", str(err)) user_input[CONF_MODEL],
errors["base"] = "cannot_connect" ", ".join(available_models)
except Exception as err: # pylint: disable=broad-except )
_LOGGER.exception("Unexpected error: %s", str(err))
errors["base"] = "unknown" except vol.Invalid as err:
_LOGGER.error("Validation error: %s", str(err))
errors["base"] = "invalid_input"
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
@@ -133,21 +242,33 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_TEMPERATURE, DEFAULT_TEMPERATURE CONF_TEMPERATURE, DEFAULT_TEMPERATURE
), ),
description={"suggested_value": DEFAULT_TEMPERATURE}, description={"suggested_value": DEFAULT_TEMPERATURE},
): vol.All(vol.Coerce(float), vol.Range(min=0, max=2)), ): vol.All(
vol.Coerce(float),
vol.Range(min=0, max=2),
msg="Temperature must be between 0 and 2"
),
vol.Optional( vol.Optional(
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
default=self.config_entry.options.get( default=self.config_entry.options.get(
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
), ),
description={"suggested_value": DEFAULT_MAX_TOKENS}, description={"suggested_value": DEFAULT_MAX_TOKENS},
): vol.All(vol.Coerce(int), vol.Range(min=1, max=4096)), ): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=4096),
msg="Max tokens must be between 1 and 4096"
),
vol.Optional( vol.Optional(
CONF_REQUEST_INTERVAL, CONF_REQUEST_INTERVAL,
default=self.config_entry.options.get( default=self.config_entry.options.get(
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
), ),
description={"suggested_value": DEFAULT_REQUEST_INTERVAL}, description={"suggested_value": DEFAULT_REQUEST_INTERVAL},
): vol.All(vol.Coerce(float), vol.Range(min=0.1)), ): vol.All(
vol.Coerce(float),
vol.Range(min=0.1),
msg="Request interval must be at least 0.1 seconds"
),
}) })
return self.async_show_form( return self.async_show_form(
+50 -1
View File
@@ -2,7 +2,7 @@
from typing import Final from typing import Final
from homeassistant.const import Platform from homeassistant.const import Platform
# Domain # Domain and platforms
DOMAIN: Final = "ha_text_ai" DOMAIN: Final = "ha_text_ai"
PLATFORMS: Final = [Platform.SENSOR] PLATFORMS: Final = [Platform.SENSOR]
@@ -19,6 +19,9 @@ DEFAULT_TEMPERATURE: Final = 0.7
DEFAULT_MAX_TOKENS: Final = 1000 DEFAULT_MAX_TOKENS: Final = 1000
DEFAULT_API_ENDPOINT: Final = "https://api.openai.com/v1" DEFAULT_API_ENDPOINT: Final = "https://api.openai.com/v1"
DEFAULT_REQUEST_INTERVAL: Final = 1.0 DEFAULT_REQUEST_INTERVAL: Final = 1.0
DEFAULT_TIMEOUT: Final = 30
DEFAULT_QUEUE_SIZE: Final = 100
DEFAULT_HISTORY_LIMIT: Final = 50
# Parameter constraints # Parameter constraints
MIN_TEMPERATURE: Final = 0.0 MIN_TEMPERATURE: Final = 0.0
@@ -26,6 +29,8 @@ MAX_TEMPERATURE: Final = 2.0
MIN_MAX_TOKENS: Final = 1 MIN_MAX_TOKENS: Final = 1
MAX_MAX_TOKENS: Final = 4096 MAX_MAX_TOKENS: Final = 4096
MIN_REQUEST_INTERVAL: Final = 0.1 MIN_REQUEST_INTERVAL: Final = 0.1
MIN_TIMEOUT: Final = 5
MAX_TIMEOUT: Final = 120
# Service names # Service names
SERVICE_ASK_QUESTION: Final = "ask_question" SERVICE_ASK_QUESTION: Final = "ask_question"
@@ -49,6 +54,10 @@ ATTR_MAX_TOKENS: Final = "max_tokens"
ATTR_TOTAL_RESPONSES: Final = "total_responses" ATTR_TOTAL_RESPONSES: Final = "total_responses"
ATTR_SYSTEM_PROMPT: Final = "system_prompt" ATTR_SYSTEM_PROMPT: Final = "system_prompt"
ATTR_RESPONSE_TIME: Final = "response_time" ATTR_RESPONSE_TIME: Final = "response_time"
ATTR_QUEUE_SIZE: Final = "queue_size"
ATTR_API_STATUS: Final = "api_status"
ATTR_ERROR_COUNT: Final = "error_count"
ATTR_LAST_ERROR: Final = "last_error"
# Error messages # Error messages
ERROR_INVALID_API_KEY: Final = "invalid_api_key" ERROR_INVALID_API_KEY: Final = "invalid_api_key"
@@ -58,6 +67,9 @@ ERROR_INVALID_MODEL: Final = "invalid_model"
ERROR_RATE_LIMIT: Final = "rate_limit_exceeded" ERROR_RATE_LIMIT: Final = "rate_limit_exceeded"
ERROR_CONTEXT_LENGTH: Final = "context_length_exceeded" ERROR_CONTEXT_LENGTH: Final = "context_length_exceeded"
ERROR_API_ERROR: Final = "api_error" ERROR_API_ERROR: Final = "api_error"
ERROR_TIMEOUT: Final = "timeout_error"
ERROR_QUEUE_FULL: Final = "queue_full"
ERROR_INVALID_PROMPT: Final = "invalid_prompt"
# Configuration descriptions # Configuration descriptions
CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses" CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses"
@@ -76,17 +88,54 @@ ATTR_MAX_TOKENS_DESCRIPTION: Final = "Current max tokens setting"
ATTR_TOTAL_RESPONSES_DESCRIPTION: Final = "Total number of responses" ATTR_TOTAL_RESPONSES_DESCRIPTION: Final = "Total number of responses"
ATTR_SYSTEM_PROMPT_DESCRIPTION: Final = "Current system prompt" ATTR_SYSTEM_PROMPT_DESCRIPTION: Final = "Current system prompt"
ATTR_RESPONSE_TIME_DESCRIPTION: Final = "Time taken for last response" ATTR_RESPONSE_TIME_DESCRIPTION: Final = "Time taken for last response"
ATTR_QUEUE_SIZE_DESCRIPTION: Final = "Current size of question queue"
ATTR_API_STATUS_DESCRIPTION: Final = "Current API connection status"
ATTR_ERROR_COUNT_DESCRIPTION: Final = "Total number of errors"
ATTR_LAST_ERROR_DESCRIPTION: Final = "Last error message"
# Entity attributes # Entity attributes
ENTITY_NAME: Final = "HA Text AI" ENTITY_NAME: Final = "HA Text AI"
ENTITY_ICON: Final = "mdi:robot" ENTITY_ICON: Final = "mdi:robot"
ENTITY_ICON_ERROR: Final = "mdi:robot-dead"
ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited"
# Translation keys # Translation keys
TRANSLATION_KEY_CONFIG: Final = "config" TRANSLATION_KEY_CONFIG: Final = "config"
TRANSLATION_KEY_OPTIONS: Final = "options" TRANSLATION_KEY_OPTIONS: Final = "options"
TRANSLATION_KEY_ERROR: Final = "error" TRANSLATION_KEY_ERROR: Final = "error"
TRANSLATION_KEY_STATE: Final = "state"
TRANSLATION_KEY_SERVICES: Final = "services"
# State attributes # State attributes
STATE_READY: Final = "ready" STATE_READY: Final = "ready"
STATE_PROCESSING: Final = "processing" STATE_PROCESSING: Final = "processing"
STATE_ERROR: Final = "error" STATE_ERROR: Final = "error"
STATE_DISCONNECTED: Final = "disconnected"
STATE_RATE_LIMITED: Final = "rate_limited"
STATE_INITIALIZING: Final = "initializing"
# Logging
LOGGER_NAME: Final = "custom_components.ha_text_ai"
LOG_LEVEL_DEFAULT: Final = "INFO"
# Queue constants
QUEUE_TIMEOUT: Final = 5
QUEUE_MAX_SIZE: Final = 100
# API constants
API_TIMEOUT: Final = 30
API_RETRY_COUNT: Final = 3
API_BACKOFF_FACTOR: Final = 1.5
# Service schema constants
SCHEMA_QUESTION: Final = "question"
SCHEMA_MODEL: Final = "model"
SCHEMA_TEMPERATURE: Final = "temperature"
SCHEMA_MAX_TOKENS: Final = "max_tokens"
SCHEMA_PROMPT: Final = "prompt"
SCHEMA_LIMIT: Final = "limit"
# Event names
EVENT_RESPONSE_RECEIVED: Final = f"{DOMAIN}_response_received"
EVENT_ERROR_OCCURRED: Final = f"{DOMAIN}_error_occurred"
EVENT_STATE_CHANGED: Final = f"{DOMAIN}_state_changed"
+128 -67
View File
@@ -1,48 +1,13 @@
"""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.""" """Data coordinator for HA text AI."""
import asyncio import asyncio
import logging import logging
from datetime import timedelta from datetime import timedelta
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import openai from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.exceptions import ConfigEntryAuthFailed import async_timeout
from .const import DOMAIN from .const import DOMAIN
@@ -60,6 +25,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
temperature: float, temperature: float,
max_tokens: int, max_tokens: int,
request_interval: float, request_interval: float,
session: Optional[Any] = None,
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
super().__init__( super().__init__(
@@ -69,6 +35,28 @@ class HATextAICoordinator(DataUpdateCoordinator):
update_interval=timedelta(seconds=request_interval), update_interval=timedelta(seconds=request_interval),
) )
self._validate_params(api_key, temperature, max_tokens)
self.api_key = api_key
self.endpoint = endpoint
self.model = model
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
self._is_ready = False
self._error_count = 0
self._MAX_ERRORS = 3
self.client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.endpoint,
http_client=session,
)
def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None:
"""Validate initialization parameters."""
if not api_key: if not api_key:
raise ValueError("API key is required") raise ValueError("API key is required")
if not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2: if not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2:
@@ -76,44 +64,79 @@ class HATextAICoordinator(DataUpdateCoordinator):
if not isinstance(max_tokens, int) or max_tokens < 1: if not isinstance(max_tokens, int) or max_tokens < 1:
raise ValueError("Max tokens must be a positive integer") raise ValueError("Max tokens must be a positive integer")
self.api_key = api_key
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
self.client = openai.OpenAI(
api_key=self.api_key,
base_url=self.endpoint
)
async def _async_update_data(self) -> Dict[str, Any]: async def _async_update_data(self) -> Dict[str, Any]:
"""Update data via OpenAI API.""" """Update data via OpenAI API."""
if self._question_queue.empty(): if self._question_queue.empty():
return self._responses return self._responses
try: try:
question = await self._question_queue.get() async with async_timeout.timeout(30):
response_content = await self.hass.async_add_executor_job( question = await self._question_queue.get()
self._make_api_call, question try:
response_content = await self._make_api_call(question)
self._responses[question] = {
"question": question,
"response": response_content,
"error": None,
"timestamp": self.hass.loop.time()
}
self._error_count = 0
self._is_ready = True
_LOGGER.debug("Response received for question: %s", question)
except Exception as err:
self._handle_api_error(question, err)
finally:
self._question_queue.task_done()
return self._responses
except asyncio.TimeoutError as err:
_LOGGER.error("Timeout while processing question")
await self._handle_timeout_error()
return self._responses
def _handle_api_error(self, question: str, error: Exception) -> None:
"""Handle API errors."""
self._error_count += 1
error_msg = str(error)
if isinstance(error, AuthenticationError):
error_msg = "Authentication failed - invalid API key"
self._is_ready = False
elif isinstance(error, RateLimitError):
error_msg = "Rate limit exceeded"
elif isinstance(error, APIError):
error_msg = f"API error: {error}"
self._responses[question] = {
"question": question,
"response": None,
"error": error_msg,
"timestamp": self.hass.loop.time()
}
_LOGGER.error("API error (%s): %s", type(error).__name__, error_msg)
if self._error_count >= self._MAX_ERRORS:
_LOGGER.warning(
"Multiple errors occurred (%d). Coordinator needs attention.",
self._error_count
) )
self._responses[question] = {
"question": question,
"response": response_content
}
_LOGGER.debug("Response from API: %s", response_content)
return self._responses
except openai.AuthenticationError as err: async def _handle_timeout_error(self) -> None:
raise ConfigEntryAuthFailed from err """Handle timeout errors."""
except Exception as err: self._error_count += 1
_LOGGER.error("Error communicating with API: %s", err) if not self._question_queue.empty():
return self._responses try:
# Clear the queue if we have timeout issues
while not self._question_queue.empty():
self._question_queue.get_nowait()
self._question_queue.task_done()
except Exception as err:
_LOGGER.error("Error clearing question queue: %s", err)
def _make_api_call(self, question: str) -> str: async def _make_api_call(self, question: str) -> str:
"""Make API call to OpenAI.""" """Make API call to OpenAI."""
try: try:
messages = [] messages = []
@@ -121,13 +144,51 @@ class HATextAICoordinator(DataUpdateCoordinator):
messages.append({"role": "system", "content": self.system_prompt}) messages.append({"role": "system", "content": self.system_prompt})
messages.append({"role": "user", "content": question}) messages.append({"role": "user", "content": question})
completion = self.client.chat.completions.create( completion = await self.client.chat.completions.create(
model=self.model, model=self.model,
messages=messages, messages=messages,
temperature=self.temperature, temperature=self.temperature,
max_tokens=self.max_tokens, max_tokens=self.max_tokens,
) )
return completion.choices[0].message.content return completion.choices[0].message.content
except Exception as err: except Exception as err:
_LOGGER.error("Error in API call: %s", err) _LOGGER.error("Error in API call: %s", err)
raise raise
async def async_ask_question(self, question: str) -> None:
"""Add question to queue."""
if not self._is_ready and self._error_count >= self._MAX_ERRORS:
_LOGGER.warning("Coordinator is not ready due to previous errors")
return
await self._question_queue.put(question)
await self.async_refresh()
async def async_shutdown(self) -> None:
"""Shutdown the coordinator."""
try:
# Clear the queue
while not self._question_queue.empty():
self._question_queue.get_nowait()
self._question_queue.task_done()
await self.client.close()
self._is_ready = False
except Exception as err:
_LOGGER.error("Error during shutdown: %s", err)
@property
def is_ready(self) -> bool:
"""Return if coordinator is ready."""
return self._is_ready
@property
def error_count(self) -> int:
"""Return current error count."""
return self._error_count
def reset_error_count(self) -> None:
"""Reset error counter."""
self._error_count = 0
+1 -1
View File
@@ -9,6 +9,6 @@
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues", "issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
"requirements": ["openai>=1.0.0"], "requirements": ["openai>=1.0.0"],
"ssdp": [], "ssdp": [],
"version": "1.0.3", "version": "1.0.4",
"zeroconf": [] "zeroconf": []
} }
+94 -51
View File
@@ -1,7 +1,7 @@
"""Sensor platform for HA text AI.""" """Sensor platform for HA text AI."""
from datetime import datetime from datetime import datetime
import logging import logging
from typing import Any, Callable, Dict, Optional from typing import Any, Dict, Optional
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
@@ -24,6 +24,21 @@ from .const import (
ATTR_TEMPERATURE, ATTR_TEMPERATURE,
ATTR_MAX_TOKENS, ATTR_MAX_TOKENS,
ATTR_TOTAL_RESPONSES, ATTR_TOTAL_RESPONSES,
ATTR_SYSTEM_PROMPT,
ATTR_QUEUE_SIZE,
ATTR_API_STATUS,
ATTR_ERROR_COUNT,
ATTR_LAST_ERROR,
ATTR_RESPONSE_TIME,
ENTITY_ICON,
ENTITY_ICON_ERROR,
ENTITY_ICON_PROCESSING,
STATE_READY,
STATE_PROCESSING,
STATE_ERROR,
STATE_DISCONNECTED,
STATE_RATE_LIMITED,
STATE_INITIALIZING,
) )
from .coordinator import HATextAICoordinator from .coordinator import HATextAICoordinator
@@ -44,7 +59,6 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_state_class = SensorStateClass.MEASUREMENT _attr_state_class = SensorStateClass.MEASUREMENT
_attr_device_class = SensorDeviceClass.TIMESTAMP _attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_icon = "mdi:robot"
def __init__( def __init__(
self, self,
@@ -57,6 +71,18 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
self._attr_unique_id = f"{config_entry.entry_id}" self._attr_unique_id = f"{config_entry.entry_id}"
self._attr_name = "Last Response" self._attr_name = "Last Response"
self._attr_suggested_display_precision = 0 self._attr_suggested_display_precision = 0
self._error_count = 0
self._last_error = None
self._state = STATE_INITIALIZING
@property
def icon(self) -> str:
"""Return the icon based on the current state."""
if self._state == STATE_PROCESSING:
return ENTITY_ICON_PROCESSING
elif self._state in [STATE_ERROR, STATE_DISCONNECTED, STATE_RATE_LIMITED]:
return ENTITY_ICON_ERROR
return ENTITY_ICON
@property @property
def state(self) -> StateType: def state(self) -> StateType:
@@ -64,75 +90,92 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
if not self.coordinator.data or not self.coordinator.last_update_success_time: if not self.coordinator.data or not self.coordinator.last_update_success_time:
return None return None
# Convert to local time try:
if isinstance(self.coordinator.last_update_success_time, datetime): if isinstance(self.coordinator.last_update_success_time, datetime):
return dt_util.as_local(self.coordinator.last_update_success_time) return dt_util.as_local(self.coordinator.last_update_success_time)
return self.coordinator.last_update_success_time return self.coordinator.last_update_success_time
except Exception as err:
_LOGGER.error("Error getting state: %s", err, exc_info=True)
return None
@property @property
def extra_state_attributes(self) -> Optional[Dict[str, Any]]: def extra_state_attributes(self) -> Dict[str, Any]:
"""Return entity specific state attributes.""" """Return entity specific state attributes."""
attributes = {
ATTR_TOTAL_RESPONSES: 0,
ATTR_MODEL: self.coordinator.model,
ATTR_TEMPERATURE: self.coordinator.temperature,
ATTR_MAX_TOKENS: self.coordinator.max_tokens,
ATTR_SYSTEM_PROMPT: self.coordinator.system_prompt,
ATTR_QUEUE_SIZE: self.coordinator._question_queue.qsize(),
ATTR_API_STATUS: self._state,
ATTR_ERROR_COUNT: self._error_count,
ATTR_LAST_ERROR: self._last_error,
}
if not self.coordinator.data: if not self.coordinator.data:
return { return attributes
ATTR_TOTAL_RESPONSES: 0,
ATTR_MODEL: self.coordinator.model,
ATTR_TEMPERATURE: self.coordinator.temperature,
ATTR_MAX_TOKENS: self.coordinator.max_tokens,
}
try: try:
history = list(self.coordinator.data.items()) history = list(self.coordinator.data.items())
if not history: if history:
return { last_question, last_data = history[-1]
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)
response_time = last_data.get("response_time")
else:
last_response = str(last_data)
last_updated = self.coordinator.last_update_success_time
response_time = None
# Handle different response formats # Convert timestamp to local time if needed
if isinstance(last_data, dict): if isinstance(last_updated, datetime):
last_response = last_data.get("response", "") last_updated = dt_util.as_local(last_updated)
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 attributes.update({
if isinstance(last_updated, datetime): ATTR_QUESTION: last_question,
last_updated = dt_util.as_local(last_updated) ATTR_RESPONSE: last_response,
ATTR_LAST_UPDATED: last_updated,
ATTR_TOTAL_RESPONSES: len(history),
})
if response_time is not None:
attributes[ATTR_RESPONSE_TIME] = response_time
return attributes
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: except Exception as err:
_LOGGER.error("Error getting attributes: %s", err, exc_info=True) _LOGGER.error("Error getting attributes: %s", err, exc_info=True)
return { self._error_count += 1
ATTR_TOTAL_RESPONSES: 0, self._last_error = str(err)
ATTR_MODEL: self.coordinator.model, self._state = STATE_ERROR
ATTR_TEMPERATURE: self.coordinator.temperature, return attributes
ATTR_MAX_TOKENS: self.coordinator.max_tokens,
}
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
return self.coordinator.last_update_success 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: async def async_added_to_hass(self) -> None:
"""When entity is added to hass.""" """When entity is added to hass."""
await super().async_added_to_hass() await super().async_added_to_hass()
self._handle_coordinator_update() self._handle_coordinator_update()
self._state = STATE_READY
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
try:
if self.coordinator.data:
self._state = STATE_READY
else:
self._state = STATE_DISCONNECTED
except Exception as err:
_LOGGER.error("Error handling update: %s", err, exc_info=True)
self._error_count += 1
self._last_error = str(err)
self._state = STATE_ERROR
self.async_write_ha_state()
+57 -10
View File
@@ -3,16 +3,22 @@ ask_question:
description: >- description: >-
Send a question to the AI model and receive a detailed response. 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. The response will be stored in the conversation history and can be retrieved later.
Response time may vary based on model selection and server load.
fields: fields:
question: question:
name: Question name: Question
description: >- description: >-
Your question or prompt for the AI assistant. Be specific and clear for better results. 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. You can ask about home automation, technical advice, or general questions.
For complex queries, consider breaking them into smaller parts.
required: true required: true
example: | example: |
What automations would you recommend for a smart kitchen? What automations would you recommend for a smart kitchen?
Consider energy efficiency and convenience. Consider energy efficiency, convenience, and integration with:
- Smart lighting
- Appliance control
- Temperature monitoring
- Voice commands
selector: selector:
text: text:
multiline: true multiline: true
@@ -23,6 +29,7 @@ ask_question:
description: >- description: >-
Select an AI model to use (optional, overrides default setting). Select an AI model to use (optional, overrides default setting).
Different models have different capabilities and token limits. Different models have different capabilities and token limits.
Note: More capable models may have longer response times and higher API costs.
required: false required: false
example: "gpt-3.5-turbo" example: "gpt-3.5-turbo"
default: "gpt-3.5-turbo" default: "gpt-3.5-turbo"
@@ -31,19 +38,24 @@ ask_question:
options: options:
- label: "GPT-3.5 Turbo (Fast & Efficient)" - label: "GPT-3.5 Turbo (Fast & Efficient)"
value: "gpt-3.5-turbo" value: "gpt-3.5-turbo"
- label: "GPT-3.5 Turbo 16K (Extended)"
value: "gpt-3.5-turbo-16k"
- label: "GPT-4 (Most Capable)" - label: "GPT-4 (Most Capable)"
value: "gpt-4" value: "gpt-4"
- label: "GPT-4 32K (Extended Context)" - label: "GPT-4 32K (Extended Context)"
value: "gpt-4-32k" value: "gpt-4-32k"
- label: "GPT-4 Turbo (Latest)"
value: "gpt-4-1106-preview"
mode: dropdown mode: dropdown
temperature: temperature:
name: Temperature name: Temperature
description: >- description: >-
Controls response creativity (0-2): Controls response creativity (0-2):
0.0-0.3: Focused, consistent responses 0.0-0.3: Focused, consistent responses (best for technical/factual queries)
0.4-0.7: Balanced responses 0.4-0.7: Balanced responses (recommended for most uses)
0.8-2.0: More creative, varied responses 0.8-2.0: More creative, varied responses (best for brainstorming)
Note: Higher values may produce less predictable results.
required: false required: false
default: 0.7 default: 0.7
selector: selector:
@@ -59,9 +71,10 @@ ask_question:
description: >- description: >-
Maximum length of the response. Higher values allow longer responses but use more API tokens. Maximum length of the response. Higher values allow longer responses but use more API tokens.
Recommended ranges: Recommended ranges:
- Short responses: 256-512 - Short responses (256-512): Quick answers, status updates
- Medium responses: 512-1024 - Medium responses (512-1024): Detailed explanations, instructions
- Long responses: 1024-4096 - Long responses (1024-4096): Complex analysis, multiple examples
Note: Actual response length may be shorter based on content.
required: false required: false
default: 1000 default: 1000
selector: selector:
@@ -75,20 +88,22 @@ clear_history:
name: Clear History name: Clear History
description: >- description: >-
Delete all stored questions and responses from the conversation history. Delete all stored questions and responses from the conversation history.
This action cannot be undone. This action cannot be undone. Consider using 'get_history' first if you need to backup the data.
System prompt settings will be preserved.
fields: {} fields: {}
get_history: get_history:
name: Get History name: Get History
description: >- description: >-
Retrieve recent conversation history, including questions, responses, and timestamps. Retrieve recent conversation history, including questions, responses, and timestamps.
Results are ordered from newest to oldest. Results are ordered from newest to oldest and include metadata like model used and response times.
fields: fields:
limit: limit:
name: Limit name: Limit
description: >- description: >-
Number of most recent conversations to return. Number of most recent conversations to return (1-100).
Higher values return more history but may take longer to process. Higher values return more history but may take longer to process.
Default: 10 conversations
required: false required: false
default: 10 default: 10
selector: selector:
@@ -98,17 +113,36 @@ get_history:
step: 1 step: 1
mode: box mode: box
filter_model:
name: Filter by Model
description: >-
Only return conversations using a specific AI model.
Leave empty to show all models.
required: false
selector:
select:
options:
- label: "All Models"
value: ""
- label: "GPT-3.5 Turbo"
value: "gpt-3.5-turbo"
- label: "GPT-4"
value: "gpt-4"
mode: dropdown
set_system_prompt: set_system_prompt:
name: Set System Prompt name: Set System Prompt
description: >- description: >-
Configure the AI's behavior by setting a system prompt. Configure the AI's behavior by setting a system prompt.
This affects how the AI interprets and responds to all future questions. This affects how the AI interprets and responds to all future questions.
The prompt will persist until changed or cleared.
fields: fields:
prompt: prompt:
name: System Prompt name: System Prompt
description: >- description: >-
Instructions that define how the AI should behave and respond. Instructions that define how the AI should behave and respond.
Be specific about the desired expertise, tone, and format of responses. Be specific about the desired expertise, tone, and format of responses.
Maximum length: 1000 characters.
required: true required: true
example: | example: |
You are a home automation expert assistant. Focus on: You are a home automation expert assistant. Focus on:
@@ -117,7 +151,20 @@ set_system_prompt:
3. Integration with popular smart home platforms 3. Integration with popular smart home platforms
4. Security and privacy considerations 4. Security and privacy considerations
Provide detailed but concise responses with clear steps when applicable. Provide detailed but concise responses with clear steps when applicable.
Format complex responses with bullet points or numbered lists.
Include warnings about potential risks or limitations.
selector: selector:
text: text:
multiline: true multiline: true
type: text type: text
max_length: 1000
clear_prompt:
name: Clear Existing Prompt
description: >-
Set to true to remove the current system prompt before applying the new one.
This ensures no conflicting instructions remain.
required: false
default: false
selector:
boolean: {}
@@ -2,32 +2,32 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Set up HA text AI", "title": "Set up HA Text AI",
"description": "Configure your OpenAI integration for smart home interactions. You'll need an OpenAI API key to proceed.", "description": "Configure your OpenAI integration for smart home interactions. You'll need an OpenAI API key from platform.openai.com to proceed.",
"data": { "data": {
"api_key": { "api_key": {
"name": "OpenAI API Key", "name": "OpenAI API Key",
"description": "Your OpenAI API key from platform.openai.com" "description": "Your OpenAI API key from platform.openai.com. Keep this secure and never share it."
}, },
"model": { "model": {
"name": "AI Model", "name": "AI Model",
"description": "Select the AI model to use. GPT-3.5-Turbo is recommended for most uses." "description": "Select the AI model to use. GPT-3.5-Turbo is recommended for most uses as it offers the best balance of capabilities and cost."
}, },
"temperature": { "temperature": {
"name": "Temperature", "name": "Temperature",
"description": "Controls response creativity (0-2). Lower values for focused responses, higher for creative ones." "description": "Controls response creativity (0-2). Low values (0.1-0.3) for focused responses, high values (0.8-2.0) for creative ones."
}, },
"max_tokens": { "max_tokens": {
"name": "Max Tokens", "name": "Max Tokens",
"description": "Maximum length of responses. Higher values allow longer responses but use more API tokens." "description": "Maximum length of responses. Higher values allow longer responses but consume more API tokens. Recommended: 512-1024."
}, },
"api_endpoint": { "api_endpoint": {
"name": "API Endpoint", "name": "API Endpoint",
"description": "OpenAI API endpoint URL. Leave default unless using a custom endpoint." "description": "OpenAI API endpoint URL. Leave default unless using a custom endpoint or proxy."
}, },
"request_interval": { "request_interval": {
"name": "Request Interval", "name": "Request Interval",
"description": "Minimum time between API requests in seconds. Increase if hitting rate limits." "description": "Minimum time between API requests in seconds. Increase if experiencing rate limits."
} }
} }
} }
@@ -40,7 +40,9 @@
"invalid_model": "Selected model is not available. Please choose a different model.", "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.", "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.", "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." "api_error": "OpenAI API error. Please check the logs for details.",
"timeout": "API response timeout. Request took too long to complete.",
"queue_full": "Request queue is full. Please try again later."
}, },
"abort": { "abort": {
"already_configured": "This OpenAI integration is already configured", "already_configured": "This OpenAI integration is already configured",
@@ -51,20 +53,20 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"title": "HA text AI Options", "title": "HA Text AI Options",
"description": "Adjust your OpenAI integration settings. Changes will apply to future requests.", "description": "Adjust your OpenAI integration settings. Changes will apply to future requests only.",
"data": { "data": {
"temperature": { "temperature": {
"name": "Temperature", "name": "Temperature",
"description": "Controls response creativity (0-2). Lower values for focused responses, higher for creative ones." "description": "Controls response creativity (0-2). Low values for focused responses, high for creative ones."
}, },
"max_tokens": { "max_tokens": {
"name": "Max Tokens", "name": "Max Tokens",
"description": "Maximum length of responses. Higher values allow longer responses but use more API tokens." "description": "Maximum length of responses. Higher values allow longer responses but consume more API tokens."
}, },
"request_interval": { "request_interval": {
"name": "Request Interval", "name": "Request Interval",
"description": "Minimum time between API requests in seconds. Increase if hitting rate limits." "description": "Minimum time between API requests in seconds. Increase if experiencing rate limits."
} }
} }
} }
@@ -77,7 +79,7 @@
"state_attributes": { "state_attributes": {
"last_updated": { "last_updated": {
"name": "Last Updated", "name": "Last Updated",
"description": "Time of the last AI response" "description": "Timestamp of the last AI response"
}, },
"question": { "question": {
"name": "Last Question", "name": "Last Question",
@@ -110,6 +112,22 @@
"response_time": { "response_time": {
"name": "Response Time", "name": "Response Time",
"description": "Time taken to generate last response" "description": "Time taken to generate last response"
},
"queue_size": {
"name": "Queue Size",
"description": "Current size of request queue"
},
"api_status": {
"name": "API Status",
"description": "Current API connection status"
},
"error_count": {
"name": "Error Count",
"description": "Number of errors since last reset"
},
"last_error": {
"name": "Last Error",
"description": "Description of the last error encountered"
} }
} }
} }
@@ -118,25 +136,57 @@
"services": { "services": {
"ask_question": { "ask_question": {
"name": "Ask Question", "name": "Ask Question",
"description": "Send a question to the AI model", "description": "Send a question to the AI model and receive a detailed response. The response will be stored in conversation history.",
"fields": { "fields": {
"question": { "question": {
"name": "Question", "name": "Question",
"description": "Your question for the AI" "description": "Your question or prompt for the AI. Be specific for better results."
},
"model": {
"name": "Model",
"description": "AI model to use (optional, overrides default settings)."
},
"temperature": {
"name": "Temperature",
"description": "Response creativity level (0-2, optional)."
},
"max_tokens": {
"name": "Max Tokens",
"description": "Maximum response length (optional)."
} }
} }
}, },
"clear_history": { "clear_history": {
"name": "Clear History", "name": "Clear History",
"description": "Clear conversation history" "description": "Delete all stored conversation history. This action cannot be undone."
}, },
"get_history": { "get_history": {
"name": "Get History", "name": "Get History",
"description": "Retrieve conversation history" "description": "Retrieve conversation history, including questions, responses, and timestamps.",
"fields": {
"limit": {
"name": "Limit",
"description": "Number of recent conversations to return (default 10)."
},
"filter_model": {
"name": "Filter by Model",
"description": "Retrieve only conversations using a specific AI model."
}
}
}, },
"set_system_prompt": { "set_system_prompt": {
"name": "Set System Prompt", "name": "Set System Prompt",
"description": "Set AI behavior instructions" "description": "Configure AI behavior by setting a system prompt.",
"fields": {
"prompt": {
"name": "Prompt",
"description": "Instructions defining AI behavior and response style."
},
"clear_prompt": {
"name": "Clear Prompt",
"description": "Remove current system prompt before setting new one."
}
}
} }
} }
} }
@@ -0,0 +1,192 @@
{
"config": {
"step": {
"user": {
"title": "Настройка HA Text AI",
"description": "Настройте интеграцию OpenAI для умного дома. Требуется API ключ OpenAI. Подробнее о получении ключа на platform.openai.com",
"data": {
"api_key": {
"name": "API ключ OpenAI",
"description": "Ваш API ключ с platform.openai.com. Храните его в безопасности."
},
"model": {
"name": "AI Модель",
"description": "Выберите модель AI. GPT-3.5-Turbo рекомендуется для большинства задач как оптимальное сочетание возможностей и стоимости."
},
"temperature": {
"name": "Температура",
"description": "Контролирует креативность ответов (0-2). Низкие значения (0.1-0.3) для точных ответов, высокие (0.8-2.0) для творческих."
},
"max_tokens": {
"name": "Максимум токенов",
"description": "Максимальная длина ответов. Больше токенов = длиннее ответы, но выше расход API токенов. Рекомендуется: 512-1024."
},
"api_endpoint": {
"name": "API Endpoint",
"description": "URL API OpenAI. Оставьте значение по умолчанию, если не используете собственный endpoint."
},
"request_interval": {
"name": "Интервал запросов",
"description": "Минимальное время между API запросами в секундах. Увеличьте при превышении лимитов запросов."
}
}
}
},
"error": {
"invalid_auth": "Неверный API ключ. Проверьте ключ OpenAI и попробуйте снова.",
"cannot_connect": "Не удалось подключиться к API. Проверьте подключение к интернету и endpoint.",
"unknown": "Неожиданная ошибка. Проверьте логи для подробностей.",
"already_exists": "Этот API ключ уже используется в другой интеграции.",
"invalid_model": "Выбранная модель недоступна. Выберите другую модель.",
"rate_limit": "Превышен лимит API запросов. Попробуйте позже или увеличьте интервал запросов.",
"context_length": "Входные данные слишком длинные для выбранной модели. Уменьшите max_tokens или используйте модель с большим контекстом.",
"api_error": "Ошибка API OpenAI. Проверьте логи для подробностей.",
"timeout": "Превышено время ожидания ответа от API.",
"queue_full": "Очередь запросов переполнена. Попробуйте позже."
},
"abort": {
"already_configured": "Эта интеграция OpenAI уже настроена",
"auth_failed": "Ошибка аутентификации. Проверьте API ключ.",
"invalid_endpoint": "Указан неверный URL API endpoint"
}
},
"options": {
"step": {
"init": {
"title": "Настройки HA Text AI",
"description": "Измените настройки интеграции OpenAI. Изменения применятся к будущим запросам.",
"data": {
"temperature": {
"name": "Температура",
"description": "Контролирует креативность ответов (0-2). Низкие значения для точных ответов, высокие для творческих."
},
"max_tokens": {
"name": "Максимум токенов",
"description": "Максимальная длина ответов. Больше токенов = длиннее ответы, но выше расход API токенов."
},
"request_interval": {
"name": "Интервал запросов",
"description": "Минимальное время между API запросами в секундах. Увеличьте при превышении лимитов."
}
}
}
}
},
"entity": {
"sensor": {
"last_response": {
"name": "Последний ответ",
"state_attributes": {
"last_updated": {
"name": "Последнее обновление",
"description": "Время последнего ответа AI"
},
"question": {
"name": "Последний вопрос",
"description": "Последний заданный вопрос"
},
"response": {
"name": "Ответ AI",
"description": "Последний ответ от AI"
},
"model": {
"name": "Текущая модель",
"description": "Используемая модель AI"
},
"temperature": {
"name": "Настройка температуры",
"description": "Текущий параметр температуры"
},
"max_tokens": {
"name": "Лимит токенов",
"description": "Текущий лимит максимальных токенов"
},
"total_responses": {
"name": "Всего ответов",
"description": "Количество ответов с последнего сброса"
},
"system_prompt": {
"name": "Системный промпт",
"description": "Текущие системные инструкции для AI"
},
"response_time": {
"name": "Время ответа",
"description": "Время генерации последнего ответа"
},
"queue_size": {
"name": "Размер очереди",
"description": "Текущий размер очереди запросов"
},
"api_status": {
"name": "Статус API",
"description": "Текущий статус подключения к API"
},
"error_count": {
"name": "Счётчик ошибок",
"description": "Количество ошибок с последнего сброса"
},
"last_error": {
"name": "Последняя ошибка",
"description": "Описание последней возникшей ошибки"
}
}
}
}
},
"services": {
"ask_question": {
"name": "Задать вопрос",
"description": "Отправить вопрос модели AI и получить подробный ответ. Ответ сохраняется в истории.",
"fields": {
"question": {
"name": "Вопрос",
"description": "Ваш вопрос или запрос для AI. Будьте конкретны для лучших результатов."
},
"model": {
"name": "Модель",
"description": "Модель AI для использования (необязательно, переопределяет настройки по умолчанию)."
},
"temperature": {
"name": "Температура",
"description": "Уровень креативности ответа (0-2, необязательно)."
},
"max_tokens": {
"name": "Максимум токенов",
"description": "Максимальная длина ответа (необязательно)."
}
}
},
"clear_history": {
"name": "Очистить историю",
"description": "Удалить всю историю разговоров. Это действие нельзя отменить."
},
"get_history": {
"name": "Получить историю",
"description": "Получить историю разговоров, включая вопросы, ответы и временные метки.",
"fields": {
"limit": {
"name": "Лимит",
"description": "Количество последних разговоров для получения (по умолчанию 10)."
},
"filter_model": {
"name": "Фильтр по модели",
"description": "Получить только разговоры с определённой моделью AI."
}
}
},
"set_system_prompt": {
"name": "Установить системный промпт",
"description": "Настроить поведение AI, установив системный промпт.",
"fields": {
"prompt": {
"name": "Промпт",
"description": "Инструкции, определяющие поведение и стиль ответов AI."
},
"clear_prompt": {
"name": "Очистить промпт",
"description": "Удалить текущий системный промпт перед установкой нового."
}
}
}
}
}
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -4,6 +4,6 @@
"domains": ["sensor"], "domains": ["sensor"],
"homeassistant": "2024.11.0", "homeassistant": "2024.11.0",
"icon": "mdi:brain", "icon": "mdi:brain",
"version": "1.0.3", "version": "1.0.4",
"documentation": "https://github.com/smkrv/ha-text-ai" "documentation": "https://github.com/smkrv/ha-text-ai"
} }