Compare commits

..
26 Commits
Author SHA1 Message Date
SMKRV f6bfbd4a07 Release v1.0.8 2024-11-19 17:02:38 +03:00
SMKRV 4ddb0dc977 Translation fixes 2024-11-19 16:59:42 +03:00
SMKRV 24dc4ac4d4 Hotfix 2024-11-19 16:53:27 +03:00
SMKRV 976f4f16a3 translation fixes 2024-11-19 16:40:56 +03:00
SMKRV 2bdef2b494 Main changes:
Created global SSL_CONTEXT at module level
Removed blocking create_default_context calls from async functions
Optimized aiohttp.ClientSession handling:
Using single connector with SSL context
Session is created once for all requests in validate_api_connection
Improved resource management:
Automatic session closure using context managers
More efficient connection handling
These changes should eliminate the blocking call warning and improve
overall code performance.
2024-11-19 16:33:30 +03:00
SMKRV 42324a793b bufix 2024-11-19 16:25:44 +03:00
SMKRV 30aa894634 Release v1.0.6 2024-11-19 15:10:06 +03:00
SMKRV 398b2550a9 Release v1.0.5 2024-11-19 14:46:12 +03:00
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
SMKRV d899144149 Release v1.0.3 2024-11-19 14:00:33 +03:00
SMKRV 5d49b4a40b Release v1.0.2 2024-11-19 13:16:14 +03:00
SMKRV 1a84727cf1 Release v1.0.2 2024-11-19 13:14:00 +03:00
SMKRV d3c7e25202 Release v1.0.2 2024-11-19 13:12:41 +03:00
SMKRV 5e82b9669c Release v1.0.2 2024-11-19 13:10:18 +03:00
SMKRV 103f9d59e9 Release v1.0.1c 2024-11-19 12:46:25 +03:00
SMKRV 6ad67a5acf Release v1.0.1c 2024-11-19 12:45:26 +03:00
SMKRV 12e5778a1c Release v1.0.1b 2024-11-19 01:18:10 +03:00
SMKRV 5c85cba606 Release v1.0.1b 2024-11-19 00:59:35 +03:00
SMKRV 63d28e12d6 Release v1.0.1b 2024-11-19 00:59:15 +03:00
SMKRV 605c73c6c4 Release v1.0.1b 2024-11-19 00:56:56 +03:00
SMKRV 9d54f88520 Release v1.0.1b 2024-11-19 00:56:33 +03:00
SMKRV 34e93e0045 Release v1.0.1b 2024-11-19 00:53:21 +03:00
SMKRV 34bfd7dfe1 Release v1.0.1b 2024-11-19 00:51:57 +03:00
SMKRV e3487a48cb Release v1.0.1a 2024-11-19 00:37:43 +03:00
SMKRV 72a98d0076 bugfix 2024-11-19 00:34:14 +03:00
17 changed files with 1142 additions and 545 deletions
+32 -3
View File
@@ -2,13 +2,42 @@ name: Validate with hassfest
on: on:
push: push:
branches:
- main
- dev
paths:
- 'custom_components/**'
- '.github/workflows/**'
pull_request: pull_request:
branches:
- main
paths:
- 'custom_components/**'
- '.github/workflows/**'
schedule: schedule:
- cron: "0 0 * * *" - cron: "0 0 * * *" # Run daily at midnight UTC
workflow_dispatch: # Allow manual trigger
jobs: jobs:
validate: validate:
name: Validate with hassfest
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v3 - name: ⤵️ Check out code from GitHub
- uses: home-assistant/actions/hassfest@master 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
-24
View File
@@ -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
+209 -91
View File
@@ -1,145 +1,263 @@
# 🤖 HA text AI Integration for Home Assistant # 🤖 HA Text AI for Home Assistant
<div align="center"> <div align="center">
![GitHub release](https://img.shields.io/github/release/smkrv/ha-text-ai.svg) ![GitHub release](https://img.shields.io/github/release/smkrv/ha-text-ai.svg?style=flat-square)
![GitHub downloads](https://img.shields.io/github/downloads/smkrv/ha-text-ai/total.svg?style=flat-square)
![GitHub stars](https://img.shields.io/github/stars/smkrv/ha-text-ai.svg?style=social) ![GitHub stars](https://img.shields.io/github/stars/smkrv/ha-text-ai.svg?style=social)
![GitHub forks](https://img.shields.io/github/forks/smkrv/ha-text-ai.svg?style=social) ![GitHub last commit](https://img.shields.io/github/last-commit/smkrv/ha-text-ai.svg?style=flat-square)
![GitHub issues](https://img.shields.io/github/issues/smkrv/ha-text-ai.svg) [![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)](https://github.com/hacs/integration) [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=flat-square)](https://github.com/hacs/integration)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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">
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. Get intelligent responses, automate complex scenarios, and enhance your home automation with natural language processing.
</p> </p>
--- ---
## 📋 Table of Contents ## 🌟 Features
- [Features](#-features)
- [Installation](#-installation)
- [Configuration](#-configuration)
- [Services](#-services)
- [Advanced Usage](#-advanced-usage)
- [Troubleshooting](#-troubleshooting)
- [Contributing](#-contributing)
## ✨ Features - 🧠 **Advanced AI Integration**:
- Support for latest GPT models
- Context-aware responses
- Multi-turn conversations
- 💬 **Natural Language Control**:
- Control devices using everyday language
- Get detailed explanations and recommendations
- 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
- 🔄 **Real-time AI Interaction**: Seamless communication with OpenAI's latest models ## 📋 Prerequisites
- 📝 **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
## 🚀 Installation - Home Assistant 2023.8.0 or newer
- OpenAI API key ([Get one here](https://platform.openai.com/account/api-keys))
- Python 3.9 or newer
- Stable internet connection
## ⚡ Installation
### HACS Installation (Recommended) ### HACS Installation (Recommended)
1. Ensure [HACS](https://hacs.xyz/) is installed 1. Open HACS in Home Assistant
2. Search for "HA text AI" in HACS 2. Click the "+" button
3. Click Install 3. Search for "HA Text AI"
4. Restart Home Assistant 4. Click "Install"
5. Restart Home Assistant
<details> ### Manual Installation
<summary>Manual Installation Steps</summary> 1. Download the latest release
2. Extract and copy `custom_components/ha_text_ai` to your `custom_components` directory
```bash 3. Restart Home Assistant
# 1. Navigate to your Home Assistant configuration directory 4. Add configuration via UI or YAML
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 ## ⚙️ Configuration
### Basic 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: your_openai_api_key api_key: !secret openai_api_key
model: gpt-3.5-turbo model: gpt-3.5-turbo
temperature: 0.7
max_tokens: 1000
request_interval: 1.0
api_endpoint: https://api.openai.com/v1 # optional
``` ```
### Advanced Configuration ## 🛠️ Available Services
```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
```
## 🛠 Services ### ask_question
### Ask Question
```yaml ```yaml
service: ha-text-ai.ask_question service: ha_text_ai.ask_question
data: data:
question: "What's the weather like today?" question: "What's the optimal temperature for sleeping?"
model: "gpt-4" # optional model: "gpt-4o" # optional
temperature: 0.7 # optional temperature: 0.5 # optional
max_tokens: 500 # optional
``` ```
### More Services ### set_system_prompt
- `ha-text-ai.clear_history`: Reset conversation history ```yaml
- `ha-text-ai.get_history`: Retrieve past interactions service: ha_text_ai.set_system_prompt
- `ha-text-ai.set_system_prompt`: Configure AI behavior data:
prompt: |
You are a home automation expert focused on:
1. Energy efficiency
2. Comfort optimization
3. Security considerations
Provide practical, actionable advice.
```
## 🔍 Advanced Usage ### clear_history
```yaml
service: ha_text_ai.clear_history
```
### Automation Example ### get_history
```yaml
service: ha_text_ai.get_history
data:
limit: 5 # optional
```
## 🔧 Advanced Examples
### Smart Energy Management
```yaml ```yaml
automation: automation:
alias: "AI Energy Optimization"
trigger:
platform: time_pattern
hours: "/2"
action:
- service: ha_text_ai.ask_question
data:
question: >
Current power usage: {{ states('sensor.total_power') }}W
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 }}"
```
### Contextual Lighting Control
```yaml
automation:
alias: "AI Lighting Assistant"
trigger: trigger:
platform: state platform: state
entity_id: binary_sensor.motion entity_id: binary_sensor.motion
to: 'on' variables:
context: >
Time: {{ now().strftime('%H:%M') }}
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: "What should I do when motion is detected?" question: >
Based on this context:
{{ 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 }}
``` ```
## 🔧 Troubleshooting ## 📊 Performance Optimization
<details> ### Token Usage
<summary>Common Issues and Solutions</summary> - Use focused system prompts
- Implement response caching
- Clear history periodically
- Monitor token usage
### API Key Issues ### Response Time
- Verify API key format - Adjust request_interval
- Check API key permissions - Use faster models for simple queries
- Ensure proper configuration in secrets.yaml - Implement timeout handling
- Cache frequent responses
### Connection Problems ### Memory Management
- Verify internet connection - Set appropriate history limits
- Check API endpoint accessibility - Clear unused contexts
- Review Home Assistant logs - Monitor memory usage
</details> - Use efficient data structures
## 👥 Contributing ## ❗ Troubleshooting
We welcome contributions! Here's how you can help: ### API Issues
- Verify API key validity
- 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
Contributions welcome! Please read our [Contributing Guide](CONTRIBUTING.md).
1. Fork the repository 1. Fork the repository
2. Create a feature branch 2. Create feature branch (`git checkout -b feature/Enhancement`)
3. Commit your changes 3. Commit changes (`git commit -m 'Add Enhancement'`)
4. Push to the branch 4. Push branch (`git push origin feature/Enhancement`)
5. Submit a pull request 5. Open Pull Request
## 📝 License
MIT License - see [LICENSE](LICENSE) for details.
--- ---
<div align="center"> <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
[Report Bug](https://github.com/smkrv/ha-text-ai/issues) · [Request Feature](https://github.com/smkrv/ha-text-ai/issues)
</div> </div>
+62 -165
View File
@@ -1,207 +1,104 @@
"""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, Platform 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 from homeassistant.helpers import aiohttp_client
from homeassistant.helpers import config_validation as cv
from .const import ( from .const import (
DOMAIN, DOMAIN,
PLATFORMS, PLATFORMS,
SERVICE_ASK_QUESTION,
SERVICE_CLEAR_HISTORY,
SERVICE_GET_HISTORY,
SERVICE_SET_SYSTEM_PROMPT,
CONF_MODEL, CONF_MODEL,
CONF_TEMPERATURE, CONF_TEMPERATURE,
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
CONF_API_ENDPOINT, CONF_API_ENDPOINT,
CONF_REQUEST_INTERVAL, CONF_REQUEST_INTERVAL,
DEFAULT_MODEL,
DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS,
DEFAULT_API_ENDPOINT,
DEFAULT_REQUEST_INTERVAL,
) )
from .coordinator import HATextAICoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
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 from configuration.yaml.""" """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.
Args:
call: Service call containing question and optional parameters.
"""
try:
# Get the coordinator from the first config entry
if not hass.data[DOMAIN]:
raise HomeAssistantError("No AI Text integration configured")
coordinator = next(iter(hass.data[DOMAIN].values()))
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
except Exception as ex:
_LOGGER.error("Error asking question: %s", str(ex))
raise HomeAssistantError(f"Failed to ask question: {str(ex)}")
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")
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)}")
async def async_get_history(call: ServiceCall) -> dict[str, list]:
"""Handle the get_history service call.
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()))
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)}")
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")
coordinator = next(iter(hass.data[DOMAIN].values()))
prompt = call.data["prompt"]
coordinator.system_prompt = prompt
except Exception as ex:
_LOGGER.error("Error setting system prompt: %s", str(ex))
raise HomeAssistantError(f"Failed to set system prompt: {str(ex)}")
# Register services
hass.services.async_register(
DOMAIN,
SERVICE_ASK_QUESTION,
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."""
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(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT),
model=entry.data.get(CONF_MODEL), model=entry.data.get(CONF_MODEL, DEFAULT_MODEL),
temperature=entry.data.get(CONF_TEMPERATURE), temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
max_tokens=entry.data.get(CONF_MAX_TOKENS), max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS),
request_interval=entry.data.get(CONF_REQUEST_INTERVAL), request_interval=entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL),
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(CONF_MODEL, DEFAULT_MODEL)
)
return True
except Exception as ex: except Exception as ex:
_LOGGER.error("Error setting up entry: %s", str(ex)) _LOGGER.exception("Unexpected error setting up entry: %s", str(ex))
raise ConfigEntryNotReady from 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."""
try: try:
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()
# 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
]:
hass.services.async_remove(DOMAIN, service)
return unload_ok return unload_ok
except Exception as ex: except Exception as ex:
_LOGGER.error("Error unloading entry: %s", str(ex)) _LOGGER.exception("Error unloading entry: %s", str(ex))
return False 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))
+231 -36
View File
@@ -1,8 +1,19 @@
"""Config flow for HA text AI integration.""" """Config flow for HA text AI integration."""
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
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.core import callback from homeassistant.core import callback
from openai import AsyncOpenAI
from openai import OpenAIError, APIError, APIConnectionError, AuthenticationError, RateLimitError
from .const import ( from .const import (
DOMAIN, DOMAIN,
@@ -18,70 +29,254 @@ from .const import (
DEFAULT_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL,
) )
class HATextAIConfigFlow(config_entries.ConfigFlow): import logging
_LOGGER = logging.getLogger(__name__)
# Create SSL context at module level
SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
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)
),
})
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"
connector = aiohttp.TCPConnector(ssl=SSL_CONTEXT)
async with timeout(5):
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get(endpoint) 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."""
# Validate endpoint first
endpoint_valid, endpoint_error = await validate_endpoint(endpoint)
if not endpoint_valid:
return False, endpoint_error, []
connector = aiohttp.TCPConnector(ssl=SSL_CONTEXT)
async with aiohttp.ClientSession(connector=connector) as session:
for attempt in range(retry_count):
try:
async with timeout(10):
client = AsyncOpenAI(
api_key=api_key,
base_url=endpoint,
http_client=session
)
models = await client.models.list()
model_ids = [model.id for model in models.data]
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):
"""Handle a config flow for HA text AI.""" """Handle a config flow for HA text AI."""
VERSION = 1 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.""" """Handle the initial step."""
errors = {} errors: Dict[str, str] = {}
if user_input is not None: if user_input is not None:
return self.async_create_entry(title="HA text AI", data=user_input) try:
# Validate URL format
endpoint = user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT)
try:
result = urlparse(endpoint)
if not all([result.scheme, result.netloc]):
errors["base"] = "invalid_url_format"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors
)
except Exception as e:
_LOGGER.error("URL parsing error: %s", str(e))
errors["base"] = "invalid_url_format"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors
)
# Validate input data
user_input = STEP_USER_DATA_SCHEMA(user_input)
is_valid, error_code, available_models = await validate_api_connection(
user_input[CONF_API_KEY],
endpoint,
user_input[CONF_MODEL]
)
if is_valid:
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
)
errors["base"] = error_code
if error_code == "invalid_model":
_LOGGER.warning(
"Selected model %s not found in available models: %s",
user_input[CONF_MODEL],
", ".join(available_models)
)
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",
data_schema=vol.Schema({ data_schema=STEP_USER_DATA_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),
}),
errors=errors, errors=errors,
description_placeholders={
"default_model": DEFAULT_MODEL,
"default_endpoint": DEFAULT_API_ENDPOINT,
}
) )
@staticmethod @staticmethod
@callback @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.""" """Get the options flow for this handler."""
return OptionsFlowHandler(config_entry) return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow): class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for HA text AI.""" """Handle options flow for HA text AI."""
def __init__(self, config_entry): def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry 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.""" """Handle options flow."""
if user_input is not None: if user_input is not None:
return self.async_create_entry(title="", data=user_input) 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( return self.async_show_form(
step_id="init", step_id="init",
data_schema=vol.Schema({ data_schema=options_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),
}),
) )
+134 -23
View File
@@ -1,30 +1,141 @@
"""Constants for the HA text AI integration.""" """Constants for the HA text AI integration."""
from typing import Final
from homeassistant.const import Platform from homeassistant.const import Platform
DOMAIN = "ha-text-ai" # Domain and platforms
PLATFORMS = [Platform.SENSOR] DOMAIN: Final = "ha_text_ai"
PLATFORMS: Final = [Platform.SENSOR]
# Configuration # Configuration constants
CONF_MODEL = "model" CONF_MODEL: Final = "model"
CONF_TEMPERATURE = "temperature" CONF_TEMPERATURE: Final = "temperature"
CONF_MAX_TOKENS = "max_tokens" CONF_MAX_TOKENS: Final = "max_tokens"
CONF_API_ENDPOINT = "api_endpoint" CONF_API_ENDPOINT: Final = "api_endpoint"
CONF_REQUEST_INTERVAL = "request_interval" CONF_REQUEST_INTERVAL: Final = "request_interval"
# Defaults # Default values
DEFAULT_MODEL = "gpt-3.5-turbo" DEFAULT_MODEL: Final = "gpt-3.5-turbo"
DEFAULT_TEMPERATURE = 0.7 DEFAULT_TEMPERATURE: Final = 0.7
DEFAULT_MAX_TOKENS = 1000 DEFAULT_MAX_TOKENS: Final = 1000
DEFAULT_API_ENDPOINT = "https://api.openai.com/v1" DEFAULT_API_ENDPOINT: Final = "https://api.openai.com/v1"
DEFAULT_REQUEST_INTERVAL = 1.0 DEFAULT_REQUEST_INTERVAL: Final = 1.0
DEFAULT_TIMEOUT: Final = 30
DEFAULT_QUEUE_SIZE: Final = 100
DEFAULT_HISTORY_LIMIT: Final = 50
# Services # Parameter constraints
SERVICE_ASK_QUESTION = "ask_question" MIN_TEMPERATURE: Final = 0.0
SERVICE_CLEAR_HISTORY = "clear_history" MAX_TEMPERATURE: Final = 2.0
SERVICE_GET_HISTORY = "get_history" MIN_MAX_TOKENS: Final = 1
SERVICE_SET_SYSTEM_PROMPT = "set_system_prompt" MAX_MAX_TOKENS: Final = 4096
MIN_REQUEST_INTERVAL: Final = 0.1
MIN_TIMEOUT: Final = 5
MAX_TIMEOUT: Final = 120
# Attributes # Service names
ATTR_QUESTION = "question" SERVICE_ASK_QUESTION: Final = "ask_question"
ATTR_RESPONSE = "response" SERVICE_CLEAR_HISTORY: Final = "clear_history"
ATTR_LAST_UPDATED = "last_updated" 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"
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_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"
ERROR_TIMEOUT: Final = "timeout_error"
ERROR_QUEUE_FULL: Final = "queue_full"
ERROR_INVALID_PROMPT: Final = "invalid_prompt"
# 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"
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_NAME: Final = "HA Text AI"
ENTITY_ICON: Final = "mdi:robot"
ENTITY_ICON_ERROR: Final = "mdi:robot-dead"
ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited"
# Translation keys
TRANSLATION_KEY_CONFIG: Final = "config"
TRANSLATION_KEY_OPTIONS: Final = "options"
TRANSLATION_KEY_ERROR: Final = "error"
TRANSLATION_KEY_STATE: Final = "state"
TRANSLATION_KEY_SERVICES: Final = "services"
# State attributes
STATE_READY: Final = "ready"
STATE_PROCESSING: Final = "processing"
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"
+125 -45
View File
@@ -2,20 +2,14 @@
import asyncio import asyncio
import logging import logging
from datetime import timedelta from datetime import timedelta
from typing import Any, Dict 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 ( from .const import DOMAIN
DOMAIN,
DEFAULT_REQUEST_INTERVAL,
CONF_MODEL,
CONF_TEMPERATURE,
CONF_MAX_TOKENS,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -31,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__(
@@ -40,18 +35,34 @@ 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.api_key = api_key
self.endpoint = endpoint self.endpoint = endpoint
self.model = model self.model = model
self.temperature = temperature self.temperature = float(temperature)
self.max_tokens = max_tokens self.max_tokens = int(max_tokens)
self._question_queue = asyncio.Queue() self._question_queue = asyncio.Queue()
self._responses: Dict[str, Any] = {} self._responses: Dict[str, Any] = {}
self.system_prompt: Optional[str] = None self.system_prompt: Optional[str] = None
self._is_ready = False
self._error_count = 0
self._MAX_ERRORS = 3
openai.api_key = self.api_key self.client = AsyncOpenAI(
if endpoint != "https://api.openai.com/v1": api_key=self.api_key,
openai.api_base = endpoint 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:
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")
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."""
@@ -59,56 +70,125 @@ class HATextAICoordinator(DataUpdateCoordinator):
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
) )
response = {
"question": question,
"response": response_content
}
self._responses[question] = response
_LOGGER.debug(f"Response from API: {response}")
return self._responses
except openai.error.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 = [{"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}) messages.append({"role": "user", "content": question})
completion = openai.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: async def async_ask_question(self, question: str) -> None:
"""Add question to queue.""" """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._question_queue.put(question)
_LOGGER.debug(f"Question added to queue: {question}")
await self.async_refresh() await self.async_refresh()
def clear_history(self) -> None: async def async_shutdown(self) -> None:
"""Clear the stored question and response history.""" """Shutdown the coordinator."""
self._responses.clear() try:
_LOGGER.info("History cleared.") # Clear the queue
while not self._question_queue.empty():
self._question_queue.get_nowait()
self._question_queue.task_done()
def get_history(self, limit: int = 10) -> Dict[str, Any]: await self.client.close()
"""Get the history of questions and responses.""" self._is_ready = False
return {"history": list(self._responses.values())[-limit:]}
def set_system_prompt(self, prompt: str) -> None: except Exception as err:
"""Set a system prompt that will be used for all future questions.""" _LOGGER.error("Error during shutdown: %s", err)
self.system_prompt = prompt
_LOGGER.info(f"System prompt set: {prompt}") @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
+8 -9
View File
@@ -1,15 +1,14 @@
{ {
"domain": "ha-text-ai", "domain": "ha_text_ai",
"name": "HA text AI", "name": "HA Text AI",
"codeowners": ["@smkrv"],
"config_flow": true, "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", "issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
"requirements": ["openai>=1.0.0"], "requirements": ["openai>=1.0.0"],
"ssdp": [], "ssdp": [],
"zeroconf": [], "version": "1.0.8",
"homekit": {}, "zeroconf": []
"dependencies": [],
"codeowners": ["@smkrv"],
"version": "1.0.0",
"iot_class": "cloud_polling"
} }
+142 -19
View File
@@ -1,16 +1,49 @@
"""Sensor platform for HA text AI.""" """Sensor platform for HA text AI."""
from typing import Any, Callable, Dict, Optional from datetime import datetime
import logging
from typing import Any, 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.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity 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,
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
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
@@ -23,6 +56,10 @@ async def async_setup_entry(
class HATextAISensor(CoordinatorEntity, SensorEntity): class HATextAISensor(CoordinatorEntity, SensorEntity):
"""HA text AI Sensor.""" """HA text AI Sensor."""
_attr_has_entity_name = True
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_device_class = SensorDeviceClass.TIMESTAMP
def __init__( def __init__(
self, self,
coordinator: HATextAICoordinator, coordinator: HATextAICoordinator,
@@ -32,27 +69,113 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
super().__init__(coordinator) super().__init__(coordinator)
self._config_entry = config_entry self._config_entry = config_entry
self._attr_unique_id = f"{config_entry.entry_id}" self._attr_unique_id = f"{config_entry.entry_id}"
self._attr_name = "HA text AI" self._attr_name = "Last Response"
self._attr_state_class = SensorStateClass.MEASUREMENT 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:
"""Return the state of the sensor.""" """Return the state of the sensor."""
if self.coordinator.data: if not self.coordinator.data or not self.coordinator.last_update_success_time:
return "Ready" # Assuming "Ready" is a valid state, you might want to return something meaningful, like the last response time. return None
return "Not Ready"
try:
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
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."""
if not self.coordinator.data: attributes = {
return None ATTR_TOTAL_RESPONSES: 0,
keys = list(self.coordinator.data.keys()) ATTR_MODEL: self.coordinator.model,
values = list(self.coordinator.data.values()) ATTR_TEMPERATURE: self.coordinator.temperature,
last_question = keys[-1] ATTR_MAX_TOKENS: self.coordinator.max_tokens,
last_response = values[-1] ATTR_SYSTEM_PROMPT: self.coordinator.system_prompt,
return { ATTR_QUEUE_SIZE: self.coordinator._question_queue.qsize(),
ATTR_QUESTION: last_question, ATTR_API_STATUS: self._state,
ATTR_RESPONSE: last_response, ATTR_ERROR_COUNT: self._error_count,
ATTR_LAST_UPDATED: self.coordinator.last_update_success_time, ATTR_LAST_ERROR: self._last_error,
} }
if not self.coordinator.data:
return attributes
try:
history = list(self.coordinator.data.items())
if history:
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
# Convert timestamp to local time if needed
if isinstance(last_updated, datetime):
last_updated = dt_util.as_local(last_updated)
attributes.update({
ATTR_QUESTION: last_question,
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
except Exception as err:
_LOGGER.error("Error getting attributes: %s", err, exc_info=True)
self._error_count += 1
self._last_error = str(err)
self._state = STATE_ERROR
return attributes
@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.last_update_success
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
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()
+94 -24
View File
@@ -1,66 +1,109 @@
# Service to ask a question
ask_question: ask_question:
name: 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.
Response time may vary based on model selection and server load.
fields: fields:
question: question:
name: 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.
For complex queries, consider breaking them into smaller parts.
required: true required: true
example: "What is the weather like today?" example: |
What automations would you recommend for a smart kitchen?
Consider energy efficiency, convenience, and integration with:
- Smart lighting
- Appliance control
- Temperature monitoring
- Voice commands
selector: selector:
text: text:
multiline: true multiline: true
type: text
model: model:
name: 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.
Note: More capable models may have longer response times and higher API costs.
required: false required: false
example: "gpt-4" example: "gpt-3.5-turbo"
default: "gpt-3.5-turbo" default: "gpt-3.5-turbo"
selector: selector:
select: select:
options: options:
- "gpt-3.5-turbo" - label: "GPT-3.5 Turbo (Fast & Efficient)"
- "gpt-4" value: "gpt-3.5-turbo"
- "gpt-4-32k" - label: "GPT-3.5 Turbo 16K (Extended)"
value: "gpt-3.5-turbo-16k"
- label: "GPT-4 (Most Capable)"
value: "gpt-4"
- label: "GPT-4 32K (Extended Context)"
value: "gpt-4-32k"
- label: "GPT-4 Turbo (Latest)"
value: "gpt-4-1106-preview"
mode: dropdown
temperature: temperature:
name: 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 (best for technical/factual queries)
0.4-0.7: Balanced responses (recommended for most uses)
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:
number: number:
min: 0.0 min: 0.0
max: 1.0 max: 2.0
step: 0.1 step: 0.1
mode: slider
unit_of_measurement: ""
max_tokens: max_tokens:
name: 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): Quick answers, status updates
- Medium responses (512-1024): Detailed explanations, instructions
- 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:
number: number:
min: 1 min: 1
max: 4000 max: 4096
step: 1 step: 1
mode: box
# Service to clear response history
clear_history: clear_history:
name: 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. Consider using 'get_history' first if you need to backup the data.
System prompt settings will be preserved.
fields: {} fields: {}
# Service to get response history
get_history: get_history:
name: 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 and include metadata like model used and response times.
fields: fields:
limit: limit:
name: Limit name: Limit
description: Maximum number of history items to return description: >-
Number of most recent conversations to return (1-100).
Higher values return more history but may take longer to process.
Default: 10 conversations
required: false required: false
default: 10 default: 10
selector: selector:
@@ -68,17 +111,44 @@ get_history:
min: 1 min: 1
max: 100 max: 100
step: 1 step: 1
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
# Service to set system prompt
set_system_prompt: set_system_prompt:
name: Set System Prompt fields:
description: Set a system prompt that will be used for all future questions
fields:
prompt: prompt:
name: System 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.
Maximum length: 1000 characters.
required: true 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.
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
@@ -1,38 +1,54 @@
{ {
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Set up HA text AI", "title": "Set up HA Text AI",
"description": "Set up your OpenAI integration", "description": "Configure your OpenAI integration",
"data": { "data": {
"api_key": "API Key", "api_key": "Your OpenAI API key",
"model": "Model", "model": "AI model to use for responses",
"temperature": "Temperature", "temperature": "Temperature for response generation (0-2)",
"max_tokens": "Max Tokens", "max_tokens": "Maximum tokens in response (1-4096)",
"api_endpoint": "API Endpoint", "api_endpoint": "API endpoint URL",
"request_interval": "Request Interval (seconds)" "request_interval": "Minimum time between API requests (seconds)"
}
}
} }
}
}, },
"error": { "options": {
"auth": "API key is invalid.", "step": {
"cannot_connect": "Failed to connect to API.", "init": {
"unknown": "Unexpected error occurred." "title": "HA Text AI Options",
"data": {
"temperature": "Response temperature (0-2)",
"max_tokens": "Maximum response length",
"request_interval": "Time between requests"
}
}
}
}, },
"abort": { "services": {
"already_configured": "Device is already configured" "ask_question": {
} "name": "Ask Question",
}, "description": "Send a question to the AI model",
"options": { "fields": {
"step": { "question": {
"init": { "name": "Question",
"title": "HA text AI Options", "description": "Your question for the AI"
"data": { }
"temperature": "Temperature", }
"max_tokens": "Max Tokens", },
"request_interval": "Request Interval (seconds)" "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 system behavior instructions"
} }
}
} }
}
} }
@@ -0,0 +1,54 @@
{
"config": {
"step": {
"user": {
"title": "Настройка HA Text AI",
"description": "Настройка интеграции с OpenAI",
"data": {
"api_key": "Ваш ключ API OpenAI",
"model": "Модель ИИ для генерации ответов",
"temperature": "Температура генерации ответов (0-2)",
"max_tokens": "Максимальное количество токенов в ответе (1-4096)",
"api_endpoint": "URL конечной точки API",
"request_interval": "Минимальное время между запросами к API (секунды)"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Настройки HA Text AI",
"data": {
"temperature": "Температура ответов (0-2)",
"max_tokens": "Максимальная длина ответа",
"request_interval": "Время между запросами"
}
}
}
},
"services": {
"ask_question": {
"name": "Задать вопрос",
"description": "Отправить вопрос модели ИИ",
"fields": {
"question": {
"name": "Вопрос",
"description": "Ваш вопрос для ИИ"
}
}
},
"clear_history": {
"name": "Очистить историю",
"description": "Очистить историю разговора"
},
"get_history": {
"name": "Получить историю",
"description": "Получить историю разговора"
},
"set_system_prompt": {
"name": "Установить системный промпт",
"description": "Установить инструкции поведения системы"
}
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+4 -2
View File
@@ -2,6 +2,8 @@
"name": "HA text AI", "name": "HA text AI",
"render_readme": true, "render_readme": true,
"domains": ["sensor"], "domains": ["sensor"],
"homeassistant": "2023.8.0", "homeassistant": "2024.11.0",
"icon": "mdi:brain" "icon": "mdi:brain",
"version": "1.0.8",
"documentation": "https://github.com/smkrv/ha-text-ai"
} }
-8
View File
@@ -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()
-65
View File
@@ -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]