Compare commits

..
48 Commits
Author SHA1 Message Date
SMKRV 5037622fb6 Release v2.0.0 2024-11-20 01:02:27 +03:00
SMKRV 962a089bf4 Stability improvements 2024-11-19 23:38:07 +03:00
SMKRV 4c56565b66 Stability improvements 2024-11-19 23:30:28 +03:00
SMKRV 8432038f09 Stability improvements 2024-11-19 23:21:54 +03:00
SMKRV 929d916d41 Bugfixes 2024-11-19 19:39:05 +03:00
SMKRV 675975d951 Minor changes 2024-11-19 19:31:26 +03:00
SMKRV 524ec87395 Minor changes 2024-11-19 19:30:42 +03:00
SMKRV 94f8193996 last_update_success_time > last_update_success 2024-11-19 19:28:01 +03:00
SMKRV 7c3fcf73c3 Text edits 2024-11-19 19:23:03 +03:00
SMKRV 20bbf89679 Changes 2024-11-19 19:20:52 +03:00
SMKRV 86dca52d07 Small changes 2024-11-19 19:18:16 +03:00
SMKRV 78d1561e74 Markdown 2024-11-19 19:17:04 +03:00
SMKRV 054e4af258 Quick bugfix 2024-11-19 19:15:57 +03:00
SMKRV dc03faa97e docs: update HACS installation for custom
repository

  - Change HACS badge from Default to Custom
  - Add custom repository installation steps
  - Update installation instructions
  - Revise documentation format
2024-11-19 19:03:51 +03:00
SMKRV 9a7635c2ae Release v1.1.0 2024-11-19 18:55:14 +03:00
SMKRV df3d79c20c feat: add multi-provider support,
config improvements
2024-11-19 18:53:51 +03:00
SMKRV d6144be7ed v.1.0.10 2024-11-19 17:49:48 +03:00
SMKRV 9afbb904b3 __init__.py:
added: from .coordinator import HATextAICoordinator
2024-11-19 17:48:00 +03:00
SMKRV 93558b2444 Version update 2024-11-19 17:34:27 +03:00
SMKRV 9f93f1ee18 Main changes:
Removed the validate_endpoint function
Optimized the validate_api_connection function
Simplified API connection verification
Preserved all error handling and retry logic
Improved exception handling
The integration should now correctly verify the
OpenAI API connection without false endpoint_not_available errors.
2024-11-19 17:33:27 +03:00
SMKRV 30a9b53ba1 Markdown changes 2024-11-19 17:18:23 +03:00
SMKRV 5175970d55 structure.md added 2024-11-19 17:16:30 +03:00
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
16 changed files with 2032 additions and 567 deletions
+32 -3
View File
@@ -2,13 +2,42 @@ name: Validate with hassfest
on:
push:
branches:
- main
- dev
paths:
- 'custom_components/**'
- '.github/workflows/**'
pull_request:
branches:
- main
paths:
- 'custom_components/**'
- '.github/workflows/**'
schedule:
- cron: "0 0 * * *"
- cron: "0 0 * * *" # Run daily at midnight UTC
workflow_dispatch: # Allow manual trigger
jobs:
validate:
name: Validate with hassfest
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: home-assistant/actions/hassfest@master
- name: ⤵️ Check out code from GitHub
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🚀 Run hassfest validation
uses: home-assistant/actions/hassfest@master
- name: ️ Print hassfest version
if: always()
run: |
echo "Hassfest version: $(hassfest --version)"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
-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
+154 -101
View File
@@ -1,145 +1,198 @@
# 🤖 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 stars](https://img.shields.io/github/stars/smkrv/ha-text-ai.svg?style=social)
![GitHub forks](https://img.shields.io/github/forks/smkrv/ha-text-ai.svg?style=social)
![GitHub issues](https://img.shields.io/github/issues/smkrv/ha-text-ai.svg)
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/hacs/integration)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
![GitHub 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 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) [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=flat-square)](https://github.com/hacs/integration)
</div>
<p align="center">
Powerful OpenAI integration for Home Assistant enabling natural language interaction with your smart home
Transform your smart home experience with powerful AI assistance powered by multiple AI providers including OpenAI GPT and Anthropic Claude models. Get intelligent responses, automate complex scenarios, and enhance your home automation with advanced natural language processing.
</p>
---
## 📋 Table of Contents
- [Features](#-features)
- [Installation](#-installation)
- [Configuration](#-configuration)
- [Services](#-services)
- [Advanced Usage](#-advanced-usage)
- [Troubleshooting](#-troubleshooting)
- [Contributing](#-contributing)
## 🌟 Features
## ✨ Features
- 🧠 **Multi-Provider AI Integration**:
- Support for OpenAI GPT models
- Anthropic Claude integration
- Custom API endpoints
- Flexible model selection
- 💬 **Advanced Language Processing**:
- Context-aware responses
- Multi-turn conversations
- Custom system instructions
- Natural conversation flow
- 📝 **Enhanced Memory Management**:
- Persistent conversation history
- Context-aware responses
- Customizable history limits
- Model-specific filtering
-**Performance Optimization**:
- Efficient token usage
- Smart rate limiting
- Response caching
- Request interval control
- 🎯 **Advanced Customization**:
- Per-request model selection
- Adjustable parameters
- Custom system prompts
- Temperature control
- 🔒 **Enhanced Security**:
- Secure API key storage
- Rate limiting protection
- Error handling
- Usage monitoring
- 🎨 **Improved User Experience**:
- Intuitive configuration UI
- Detailed sensor attributes
- Rich service interface
- Model selection UI
- 🔄 **Automation Integration**:
- Event-driven responses
- Conditional logic support
- Template compatibility
- Model-specific automation
- 🔄 **Real-time AI Interaction**: Seamless communication with OpenAI's latest models
- 📝 **Conversation History**: Track and manage your AI interactions
- ⚙️ **Customizable Settings**: Fine-tune AI behavior with adjustable parameters
- 🔌 **Easy Integration**: Simple setup process through HACS or manual installation
- 🎯 **System Prompts**: Set context for more relevant AI responses
## 📋 Prerequisites
## 🚀 Installation
- Home Assistant 2023.8.0 or newer
- API key from supported providers:
- OpenAI ([Get key](https://platform.openai.com/account/api-keys))
- Anthropic ([Get key](https://console.anthropic.com/))
- Python 3.9 or newer
- Stable internet connection
### HACS Installation (Recommended)
1. Ensure [HACS](https://hacs.xyz/) is installed
2. Search for "HA text AI" in HACS
3. Click Install
4. Restart Home Assistant
## Installation
<details>
<summary>Manual Installation Steps</summary>
### HACS Installation (Recommended)
1. Open HACS in Home Assistant
2. Click on the three dots in the top right corner
3. Select "Custom repositories"
4. Add `https://github.com/smkrv/ha-text-ai` as Integration
5. Click "Add"
6. Click on "Integrations" in HACS
7. Search for "HA Text AI"
8. Click "Download"
9. Restart Home Assistant
```bash
# 1. Navigate to your Home Assistant configuration directory
cd ~/.homeassistant
# 2. Create custom_components directory if it doesn't exist
mkdir -p custom_components
# 3. Clone the repository
git clone https://github.com/smkrv/ha-text-ai.git custom_components/ha-text-ai
# 4. Restart Home Assistant
```
</details>
### Manual Installation
1. Download the latest release
2. Extract and copy `custom_components/ha_text_ai` to your `custom_components` directory
3. Restart Home Assistant
4. Add configuration via UI or YAML
## ⚙️ 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
ha-text-ai:
api_key: your_openai_api_key
model: gpt-3.5-turbo
ha_text_ai:
api_key: !secret ai_api_key
model: gpt-3.5-turbo # or claude-3-sonnet
temperature: 0.7
max_tokens: 1000
request_interval: 1.0
api_endpoint: https://api.openai.com/v1 # optional, for custom endpoints
system_prompt: |
You are a home automation expert assistant.
Focus on practical and efficient solutions.
```
### Advanced Configuration
```yaml
ha-text-ai:
api_key: your_openai_api_key
model: gpt-4
temperature: 0.8
max_tokens: 2000
api_endpoint: https://custom-endpoint.com/v1
request_interval: 2.0
```
## 🛠️ Available Services
## 🛠 Services
### Ask Question
### ask_question
```yaml
service: ha-text-ai.ask_question
service: ha_text_ai.ask_question
data:
question: "What's the weather like today?"
model: "gpt-4" # optional
temperature: 0.7 # optional
question: "What's the optimal temperature for sleeping?"
model: "claude-3-sonnet" # optional
temperature: 0.5 # optional
max_tokens: 500 # optional
system_prompt: "You are a sleep optimization expert" # optional
```
### More Services
- `ha-text-ai.clear_history`: Reset conversation history
- `ha-text-ai.get_history`: Retrieve past interactions
- `ha-text-ai.set_system_prompt`: Configure AI behavior
## 🔍 Advanced Usage
### Automation Example
### set_system_prompt
```yaml
automation:
trigger:
platform: state
entity_id: binary_sensor.motion
to: 'on'
action:
service: ha-text-ai.ask_question
data:
question: "What should I do when motion is detected?"
service: ha_text_ai.set_system_prompt
data:
prompt: |
You are a home automation expert focused on:
1. Energy efficiency
2. Comfort optimization
3. Security considerations
Provide practical, actionable advice.
```
## 🔧 Troubleshooting
### clear_history
```yaml
service: ha_text_ai.clear_history
```
<details>
<summary>Common Issues and Solutions</summary>
### get_history
```yaml
service: ha_text_ai.get_history
data:
limit: 5 # optional
filter_model: "gpt-4" # optional
```
### API Key Issues
- Verify API key format
- Check API key permissions
- Ensure proper configuration in secrets.yaml
## 📘 FAQ
### Connection Problems
- Verify internet connection
- Check API endpoint accessibility
- Review Home Assistant logs
</details>
**Q: Which AI providers are supported?**
## 👥 Contributing
A: Currently OpenAI (GPT models) and Anthropic (Claude models) are supported, with more providers planned.
We welcome contributions! Here's how you can help:
**Q: How can I reduce API costs?**
A: Use GPT-3.5-Turbo or Claude-3-Sonnet for most queries, implement caching, and optimize token usage.
**Q: Can I use custom models?**
A: Yes, you can configure custom endpoints and use any compatible model by specifying it in the configuration.
**Q: How do I switch between different AI providers?**
A: Simply change the model parameter in your configuration or service calls to use the desired provider's model.
**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
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Submit a pull request
2. Create feature branch (`git checkout -b feature/Enhancement`)
3. Commit changes (`git commit -m 'Add Enhancement'`)
4. Push branch (`git push origin feature/Enhancement`)
5. Open Pull Request
## 📝 License
MIT License - see [LICENSE](LICENSE) for details.
---
<div align="center">
**[Documentation](https://github.com/smkrv/ha-text-ai/wiki)** | **[Report Bug](https://github.com/smkrv/ha-text-ai/issues)** | **[Request Feature](https://github.com/smkrv/ha-text-ai/issues)**
Made with ❤️ for the Home Assistant Community
[Report Bug](https://github.com/smkrv/ha-text-ai/issues) · [Request Feature](https://github.com/smkrv/ha-text-ai/issues)
</div>
+333 -165
View File
@@ -1,207 +1,375 @@
"""The HA text AI integration."""
"""The HA Text AI integration."""
import logging
from typing import Any
from typing import Any, Dict, Optional
import asyncio
import voluptuous as vol
import json
from datetime import datetime
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant, ServiceCall
import homeassistant.helpers.config_validation as cv
from homeassistant.exceptions import HomeAssistantError
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers import config_validation as cv
from async_timeout import timeout
from .coordinator import HATextAICoordinator
from .const import (
DOMAIN,
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,
DEFAULT_MODEL,
DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS,
DEFAULT_API_ENDPOINT,
DEFAULT_REQUEST_INTERVAL,
API_VERSION,
API_MODELS_PATH,
API_CHAT_PATH,
API_TIMEOUT,
API_RETRY_COUNT,
API_BACKOFF_FACTOR,
LOGGER_NAME,
STATE_ERROR,
STATE_READY,
STATE_PROCESSING,
STATE_RATE_LIMITED,
STATE_MAINTENANCE,
STATE_DISCONNECTED,
STATE_RETRYING,
STATE_QUEUED,
STATE_UPDATING,
SUPPORTED_MODELS,
EVENT_RESPONSE_RECEIVED,
EVENT_ERROR_OCCURRED,
EVENT_STATE_CHANGED,
)
from .coordinator import HATextAICoordinator
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger(LOGGER_NAME)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
# Service validation schemas
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
vol.Required("question"): cv.string,
vol.Optional("system_prompt"): cv.string,
vol.Optional("model"): vol.In(SUPPORTED_MODELS),
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)
),
vol.Optional("priority"): vol.Boolean,
})
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
vol.Optional("limit", default=10): vol.All(
vol.Coerce(int), vol.Range(min=1, max=100)
),
vol.Optional("filter_model"): vol.In(SUPPORTED_MODELS),
vol.Optional("start_date"): cv.datetime,
vol.Optional("include_metadata"): vol.Boolean,
})
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
vol.Required("prompt"): cv.string,
})
async def async_check_api(session, endpoint: str, headers: dict, is_anthropic: bool = False) -> bool:
"""Check API availability for different providers."""
try:
if is_anthropic:
check_url = f"{endpoint}/v1/models"
else:
check_url = f"{endpoint}/{API_VERSION}/{API_MODELS_PATH}"
async with timeout(API_TIMEOUT):
async with session.get(check_url, headers=headers) as response:
if response.status == 200:
return True
elif response.status == 401:
raise ConfigEntryNotReady("Invalid API key")
elif response.status == 429:
_LOGGER.warning("Rate limit exceeded during API check")
return False
else:
_LOGGER.error("API check failed with status: %d", response.status)
return False
except Exception as ex:
_LOGGER.error("API check error: %s", str(ex))
return False
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up the HA text AI component from configuration.yaml."""
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,
})
)
"""Set up the HA Text AI component."""
hass.data.setdefault(DOMAIN, {
"coordinators": {},
"metrics": {
"total_requests": 0,
"total_tokens": 0,
"errors": {},
"model_usage": {},
}
})
return True
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:
session = aiohttp_client.async_get_clientsession(hass)
# Determine API type based on model
model = entry.data.get(CONF_MODEL, DEFAULT_MODEL)
is_anthropic = any(model.startswith("claude") for model in SUPPORTED_MODELS)
api_key = entry.data[CONF_API_KEY]
endpoint = entry.data.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT).rstrip('/')
# Configure headers based on API type
headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
if is_anthropic:
headers["x-api-key"] = api_key
headers["anthropic-version"] = "2023-06-01"
else:
headers["Authorization"] = f"Bearer {api_key}"
# Check API with retries
for attempt in range(API_RETRY_COUNT):
if await async_check_api(session, endpoint, headers, is_anthropic):
break
if attempt < API_RETRY_COUNT - 1:
delay = API_BACKOFF_FACTOR * (2 ** attempt)
await asyncio.sleep(delay)
else:
raise ConfigEntryNotReady("Failed to connect to API")
# Create and initialize coordinator
coordinator = HATextAICoordinator(
hass,
api_key=entry.data[CONF_API_KEY],
endpoint=entry.data.get(CONF_API_ENDPOINT),
model=entry.data.get(CONF_MODEL),
temperature=entry.data.get(CONF_TEMPERATURE),
max_tokens=entry.data.get(CONF_MAX_TOKENS),
request_interval=entry.data.get(CONF_REQUEST_INTERVAL),
api_key=api_key,
endpoint=endpoint,
model=model,
temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS),
request_interval=entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL),
session=session,
is_anthropic=is_anthropic
)
# Initialize coordinator
await coordinator.async_config_entry_first_refresh()
# Check coordinator status
if coordinator.endpoint_status == "auth_error":
raise ConfigEntryNotReady("Authentication failed")
elif coordinator.endpoint_status == "rate_limited":
_LOGGER.warning("API rate limited during setup")
elif coordinator.endpoint_status == "maintenance":
raise ConfigEntryNotReady("API is in maintenance mode")
elif coordinator.endpoint_status == "error":
raise ConfigEntryNotReady("API error during setup")
elif not coordinator.last_update_success:
raise ConfigEntryNotReady("Failed to initialize coordinator")
hass.data[DOMAIN][entry.entry_id] = coordinator
return await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register event handlers
@callback
def handle_state_change(event):
"""Handle state changes."""
if event.data.get("entity_id").startswith(f"{DOMAIN}."):
_LOGGER.debug("State changed: %s", event.data)
hass.bus.async_listen(EVENT_STATE_CHANGED, handle_state_change)
# Register services
async def async_ask_question(call: ServiceCall) -> None:
"""Handle the ask_question service call."""
question = call.data.get("question", "")
if not question:
_LOGGER.error("No question provided in service call")
return
request_params = {}
for param in ["system_prompt", "model", "temperature", "max_tokens"]:
if param in call.data:
request_params[param] = call.data[param]
try:
await coordinator.async_ask_question(question, **request_params)
except Exception as err:
_LOGGER.error("Error asking question: %s", str(err))
async def async_clear_history(call: ServiceCall) -> None:
"""Handle the clear_history service call."""
try:
coordinator._responses.clear()
await coordinator.async_refresh()
_LOGGER.info("History cleared successfully")
except Exception as err:
_LOGGER.error("Error clearing history: %s", str(err))
async def async_get_history(call: ServiceCall) -> dict:
"""Handle the get_history service call."""
try:
limit = min(int(call.data.get("limit", 10)), 100)
filter_model = str(call.data.get("filter_model", ""))
start_date = call.data.get("start_date")
include_metadata = call.data.get("include_metadata", False)
responses = coordinator._responses
metrics = {
"total_requests": coordinator.request_count,
"total_tokens": coordinator.tokens_used,
"api_version": coordinator.api_version,
"endpoint_status": coordinator.endpoint_status,
"error_count": coordinator.error_count
}
filtered_responses = responses.copy()
if filter_model:
filtered_responses = {
k: v for k, v in filtered_responses.items()
if v.get("model") == filter_model
}
if start_date:
filtered_responses = {
k: v for k, v in filtered_responses.items()
if v.get("timestamp") >= start_date
}
if not include_metadata:
filtered_responses = {
k: {
"question": v["question"],
"response": v["response"],
"timestamp": v["timestamp"]
} for k, v in filtered_responses.items()
}
sorted_responses = dict(
sorted(
filtered_responses.items(),
key=lambda x: x[1]["timestamp"],
reverse=True
)[:limit]
)
return {
"metrics": metrics,
"responses": sorted_responses
}
except Exception as err:
_LOGGER.error("Error getting history: %s", str(err))
return {}
async def async_set_system_prompt(call: ServiceCall) -> None:
"""Handle the set_system_prompt service call."""
try:
prompt = str(call.data.get("prompt", "")).strip()
if prompt:
coordinator.system_prompt = prompt
_LOGGER.info("System prompt updated successfully")
else:
_LOGGER.error("Empty prompt provided")
except Exception as err:
_LOGGER.error("Error setting system prompt: %s", str(err))
# Register services with validation
hass.services.async_register(
DOMAIN,
"ask_question",
async_ask_question,
schema=SERVICE_SCHEMA_ASK_QUESTION
)
hass.services.async_register(
DOMAIN,
"clear_history",
async_clear_history
)
hass.services.async_register(
DOMAIN,
"get_history",
async_get_history,
schema=SERVICE_SCHEMA_GET_HISTORY
)
hass.services.async_register(
DOMAIN,
"set_system_prompt",
async_set_system_prompt,
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
)
_LOGGER.info(
"Successfully set up HA Text AI with model: %s",
entry.data.get(CONF_MODEL, DEFAULT_MODEL)
)
return True
except Exception as ex:
_LOGGER.error("Error setting up entry: %s", str(ex))
_LOGGER.exception("Setup error: %s", str(ex))
raise ConfigEntryNotReady from ex
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
try:
coordinator = hass.data[DOMAIN].get(entry.entry_id)
if coordinator:
# Clear queue and history
coordinator._responses.clear()
while not coordinator._question_queue.empty():
try:
coordinator._question_queue.get_nowait()
coordinator._question_queue.task_done()
except Exception:
pass
# Close connection
await coordinator.async_shutdown()
# Remove services
for service in ["ask_question", "clear_history", "get_history", "set_system_prompt"]:
hass.services.async_remove(DOMAIN, service)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
# Only remove services if this is the last entry
if not hass.data[DOMAIN]:
for service in [
SERVICE_ASK_QUESTION,
SERVICE_CLEAR_HISTORY,
SERVICE_GET_HISTORY,
SERVICE_SET_SYSTEM_PROMPT
]:
hass.services.async_remove(DOMAIN, service)
return unload_ok
except Exception as ex:
_LOGGER.error("Error unloading entry: %s", str(ex))
_LOGGER.exception("Error unloading entry: %s", str(ex))
return False
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
if entry.version == 1:
new = {**entry.data}
# Migrate settings
if CONF_MODEL in new and new[CONF_MODEL] not in SUPPORTED_MODELS:
new[CONF_MODEL] = DEFAULT_MODEL
entry.version = 2
hass.config_entries.async_update_entry(entry, data=new)
return True
+237 -36
View File
@@ -1,8 +1,17 @@
"""Config flow for HA text AI integration."""
from typing import Any, Dict, Optional, Tuple
import voluptuous as vol
import asyncio
import aiohttp
from async_timeout import timeout
from urllib.parse import urlparse, urljoin
from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY
import homeassistant.helpers.config_validation as cv
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
DOMAIN,
@@ -16,72 +25,264 @@ from .const import (
DEFAULT_MAX_TOKENS,
DEFAULT_API_ENDPOINT,
DEFAULT_REQUEST_INTERVAL,
MIN_TEMPERATURE,
MAX_TEMPERATURE,
MIN_MAX_TOKENS,
MAX_MAX_TOKENS,
MIN_REQUEST_INTERVAL,
API_VERSION,
API_MODELS_PATH,
ERROR_INVALID_API_KEY,
ERROR_CANNOT_CONNECT,
ERROR_UNKNOWN,
ERROR_INVALID_MODEL,
ERROR_RATE_LIMIT,
ERROR_API_ERROR,
ERROR_TIMEOUT,
)
class HATextAIConfigFlow(config_entries.ConfigFlow):
import logging
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): cv.string,
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.string,
vol.Optional(
CONF_TEMPERATURE,
default=DEFAULT_TEMPERATURE
): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(
CONF_MAX_TOKENS,
default=DEFAULT_MAX_TOKENS
): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
),
vol.Optional(
CONF_API_ENDPOINT,
default=DEFAULT_API_ENDPOINT
): cv.string,
vol.Optional(
CONF_REQUEST_INTERVAL,
default=DEFAULT_REQUEST_INTERVAL
): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL)
),
}
)
async def validate_api_connection(
hass,
api_key: str,
endpoint: str,
model: str,
retry_count: int = 3,
retry_delay: float = 1.0
) -> Tuple[bool, str, list]:
"""Validate API connection with retry logic."""
session = async_get_clientsession(hass)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json; charset=utf-8",
"Accept-Charset": "utf-8"
}
base_url = endpoint.rstrip('/')
if not base_url.endswith(f"/{API_VERSION}"):
base_url = f"{base_url}/{API_VERSION}"
models_url = f"{base_url}/{API_MODELS_PATH}"
_LOGGER.debug("Attempting to connect to: %s", models_url)
for attempt in range(retry_count):
try:
async with timeout(10):
async with session.get(models_url, headers=headers) as response:
if response.status == 200:
data = await response.json()
model_ids = [m["id"] for m in data.get("data", [])]
_LOGGER.debug("Available models: %s", ", ".join(model_ids))
if model not in model_ids:
_LOGGER.warning(
"Model %s not found in available models: %s",
model,
", ".join(model_ids)
)
return False, ERROR_INVALID_MODEL, model_ids
return True, "", model_ids
elif response.status == 401:
_LOGGER.error("Authentication failed")
return False, ERROR_INVALID_API_KEY, []
elif response.status == 429:
_LOGGER.error("Rate limit exceeded")
return False, ERROR_RATE_LIMIT, []
else:
response_text = await response.text()
_LOGGER.error(
"API error: %s - %s",
response.status,
response_text
)
return False, ERROR_API_ERROR, []
except asyncio.TimeoutError:
_LOGGER.warning(
"Timeout during API validation (attempt %d/%d)",
attempt + 1,
retry_count
)
if attempt == retry_count - 1:
return False, ERROR_TIMEOUT, []
await asyncio.sleep(retry_delay)
except aiohttp.ClientError as err:
_LOGGER.error("Connection error: %s", str(err))
return False, ERROR_CANNOT_CONNECT, []
except Exception as err:
_LOGGER.exception("Unexpected error during validation: %s", str(err))
return False, ERROR_UNKNOWN, []
return False, ERROR_UNKNOWN, []
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for HA text AI."""
VERSION = 1
DOMAIN = DOMAIN # Define the domain as a class variable
async def async_step_user(self, user_input=None):
async def async_step_user(
self,
user_input: Optional[Dict[str, Any]] = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}
errors: Dict[str, str] = {}
if user_input is not None:
return self.async_create_entry(title="HA text AI", data=user_input)
try:
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
)
validated_input = STEP_USER_DATA_SCHEMA(user_input)
is_valid, error_code, available_models = await validate_api_connection(
self.hass,
validated_input[CONF_API_KEY],
endpoint,
validated_input[CONF_MODEL]
)
if is_valid:
await self.async_set_unique_id(validated_input[CONF_API_KEY])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="HA Text AI",
data=validated_input
)
errors["base"] = error_code
if error_code == ERROR_INVALID_MODEL:
_LOGGER.warning(
"Selected model %s not found in available models: %s",
validated_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(
step_id="user",
data_schema=vol.Schema({
vol.Required("api_key"): str,
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str,
vol.Optional(CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.Coerce(float),
vol.Optional(CONF_MAX_TOKENS, default=DEFAULT_MAX_TOKENS): vol.Coerce(int),
vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): str,
vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.Coerce(float),
}),
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"default_model": DEFAULT_MODEL,
"default_endpoint": DEFAULT_API_ENDPOINT,
}
)
@staticmethod
@callback
def async_get_options_flow(config_entry):
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for HA text AI."""
def __init__(self, config_entry):
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
async def async_step_init(
self,
user_input: Optional[Dict[str, Any]] = None
) -> FlowResult:
"""Handle options flow."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
options_schema = vol.Schema({
vol.Optional(
CONF_TEMPERATURE,
default=self.config_entry.options.get(
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
),
): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(
CONF_MAX_TOKENS,
default=self.config_entry.options.get(
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
),
): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
),
vol.Optional(
CONF_REQUEST_INTERVAL,
default=self.config_entry.options.get(
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
),
): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL)
),
})
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({
vol.Optional(
CONF_TEMPERATURE,
default=self.config_entry.options.get(
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
),
): vol.Coerce(float),
vol.Optional(
CONF_MAX_TOKENS,
default=self.config_entry.options.get(
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
),
): vol.Coerce(int),
vol.Optional(
CONF_REQUEST_INTERVAL,
default=self.config_entry.options.get(
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
),
): vol.Coerce(float),
}),
data_schema=options_schema,
)
+177 -23
View File
@@ -1,30 +1,184 @@
"""Constants for the HA text AI integration."""
from typing import Final
from homeassistant.const import Platform
DOMAIN = "ha-text-ai"
PLATFORMS = [Platform.SENSOR]
# Domain and platforms
DOMAIN: Final = "ha_text_ai"
PLATFORMS: Final = [Platform.SENSOR]
# Configuration
CONF_MODEL = "model"
CONF_TEMPERATURE = "temperature"
CONF_MAX_TOKENS = "max_tokens"
CONF_API_ENDPOINT = "api_endpoint"
CONF_REQUEST_INTERVAL = "request_interval"
# Configuration constants
CONF_MODEL: Final = "model"
CONF_TEMPERATURE: Final = "temperature"
CONF_MAX_TOKENS: Final = "max_tokens"
CONF_API_ENDPOINT: Final = "api_endpoint"
CONF_REQUEST_INTERVAL: Final = "request_interval"
# Defaults
DEFAULT_MODEL = "gpt-3.5-turbo"
DEFAULT_TEMPERATURE = 0.7
DEFAULT_MAX_TOKENS = 1000
DEFAULT_API_ENDPOINT = "https://api.openai.com/v1"
DEFAULT_REQUEST_INTERVAL = 1.0
# Model constants
SUPPORTED_MODELS: Final = [
"gpt-3.5-turbo",
"gpt-3.5-turbo-16k",
"gpt-4",
"gpt-4-32k",
"gpt-4-1106-preview",
"claude-3-sonnet",
"claude-3-opus"
]
# Services
SERVICE_ASK_QUESTION = "ask_question"
SERVICE_CLEAR_HISTORY = "clear_history"
SERVICE_GET_HISTORY = "get_history"
SERVICE_SET_SYSTEM_PROMPT = "set_system_prompt"
# Default values
DEFAULT_MODEL: Final = "gpt-3.5-turbo"
DEFAULT_TEMPERATURE: Final = 0.1
DEFAULT_MAX_TOKENS: Final = 1000
DEFAULT_API_ENDPOINT: Final = "https://api.openai.com"
DEFAULT_REQUEST_INTERVAL: Final = 1.0
DEFAULT_TIMEOUT: Final = 30
DEFAULT_QUEUE_SIZE: Final = 100
DEFAULT_HISTORY_LIMIT: Final = 50
DEFAULT_RETRY_COUNT: Final = 3
# Attributes
ATTR_QUESTION = "question"
ATTR_RESPONSE = "response"
ATTR_LAST_UPDATED = "last_updated"
# Parameter constraints
MIN_TEMPERATURE: Final = 0.0
MAX_TEMPERATURE: Final = 2.0
MIN_MAX_TOKENS: Final = 1
MAX_MAX_TOKENS: Final = 4096
MIN_REQUEST_INTERVAL: Final = 0.1
MIN_TIMEOUT: Final = 5
MAX_TIMEOUT: Final = 120
MAX_PROMPT_LENGTH: Final = 1000
MAX_HISTORY_LIMIT: Final = 100
# API constants
API_VERSION: Final = "v1"
API_MODELS_PATH: Final = "models"
API_CHAT_PATH: Final = "chat/completions"
API_TIMEOUT: Final = 30
API_RETRY_COUNT: Final = 3
API_BACKOFF_FACTOR: Final = 1.5
API_MAX_RETRIES: Final = 3
# History constants
HISTORY_FILTER_MODEL: Final = "filter_model"
HISTORY_FILTER_DATE: Final = "start_date"
HISTORY_SORT_ORDER: Final = "sort_order"
HISTORY_INCLUDE_METADATA: Final = "include_metadata"
# Service names
SERVICE_ASK_QUESTION: Final = "ask_question"
SERVICE_CLEAR_HISTORY: Final = "clear_history"
SERVICE_GET_HISTORY: Final = "get_history"
SERVICE_SET_SYSTEM_PROMPT: Final = "set_system_prompt"
# Service descriptions
SERVICE_ASK_QUESTION_DESCRIPTION: Final = "Ask a question to the AI model"
SERVICE_CLEAR_HISTORY_DESCRIPTION: Final = "Clear conversation history"
SERVICE_GET_HISTORY_DESCRIPTION: Final = "Get conversation history"
SERVICE_SET_SYSTEM_PROMPT_DESCRIPTION: Final = "Set system prompt for AI model"
# Attribute keys
ATTR_QUESTION: Final = "question"
ATTR_RESPONSE: Final = "response"
ATTR_LAST_UPDATED: Final = "last_updated"
ATTR_MODEL: Final = "model"
ATTR_TEMPERATURE: Final = "temperature"
ATTR_MAX_TOKENS: Final = "max_tokens"
ATTR_TOTAL_RESPONSES: Final = "total_responses"
ATTR_SYSTEM_PROMPT: Final = "system_prompt"
ATTR_RESPONSE_TIME: Final = "response_time"
ATTR_QUEUE_SIZE: Final = "queue_size"
ATTR_API_STATUS: Final = "api_status"
ATTR_ERROR_COUNT: Final = "error_count"
ATTR_LAST_ERROR: Final = "last_error"
ATTR_API_VERSION: Final = "api_version"
ATTR_ENDPOINT_STATUS: Final = "endpoint_status"
ATTR_REQUEST_COUNT: Final = "request_count"
ATTR_TOKENS_USED: Final = "tokens_used"
ATTR_RETRY_COUNT: Final = "retry_count"
ATTR_QUEUE_POSITION: Final = "queue_position"
ATTR_ESTIMATED_WAIT: Final = "estimated_wait"
# 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"
ERROR_INVALID_PARAMETERS: Final = "invalid_parameters"
ERROR_SERVICE_UNAVAILABLE: Final = "service_unavailable"
# 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"
ATTR_API_VERSION_DESCRIPTION: Final = "Current API version"
ATTR_ENDPOINT_STATUS_DESCRIPTION: Final = "Current endpoint status"
ATTR_REQUEST_COUNT_DESCRIPTION: Final = "Total number of API requests"
ATTR_TOKENS_USED_DESCRIPTION: Final = "Total tokens used"
# 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"
ENTITY_ICON_OFFLINE: Final = "mdi:robot-off"
ENTITY_ICON_QUEUE: Final = "mdi:robot-confused"
# 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"
STATE_MAINTENANCE: Final = "maintenance"
STATE_RETRYING: Final = "retrying"
STATE_QUEUED: Final = "queued"
STATE_UPDATING: Final = "updating"
# 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
# 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"
+560 -56
View File
@@ -2,19 +2,23 @@
import asyncio
import logging
from datetime import timedelta
from typing import Any, Dict
from typing import Any, Dict, Optional, List
import time
import openai
from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError
from anthropic import AsyncAnthropic
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.util import dt as dt_util
import async_timeout
from .const import (
DOMAIN,
DEFAULT_REQUEST_INTERVAL,
CONF_MODEL,
CONF_TEMPERATURE,
CONF_MAX_TOKENS,
DEFAULT_TIMEOUT,
MAX_QUEUE_SIZE,
MAX_RETRIES,
RETRY_DELAY,
SUPPORTED_MODELS,
)
_LOGGER = logging.getLogger(__name__)
@@ -31,8 +35,22 @@ class HATextAICoordinator(DataUpdateCoordinator):
temperature: float,
max_tokens: int,
request_interval: float,
session: Optional[Any] = None,
is_anthropic: bool = False,
) -> None:
"""Initialize."""
"""Initialize coordinator.
Args:
hass: HomeAssistant instance
api_key: API key for the service
endpoint: API endpoint URL
model: Model name to use
temperature: Temperature parameter for generation
max_tokens: Maximum tokens to generate
request_interval: Interval between requests
session: Optional session object
is_anthropic: Whether to use Anthropic API
"""
super().__init__(
hass,
_LOGGER,
@@ -40,75 +58,561 @@ class HATextAICoordinator(DataUpdateCoordinator):
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 = temperature
self.max_tokens = max_tokens
self._question_queue = asyncio.Queue()
self.temperature = float(temperature)
self.max_tokens = int(max_tokens)
self._question_queue = asyncio.PriorityQueue(maxsize=MAX_QUEUE_SIZE)
self._responses: Dict[str, Any] = {}
self.system_prompt: Optional[str] = None
self._is_ready = False
self._is_processing = False
self._is_rate_limited = False
self._is_maintenance = False
self._error_count = 0
self._MAX_ERRORS = 3
self._request_count = 0
self._tokens_used = 0
self._api_version = "v1"
self._endpoint_status = "disconnected"
self._performance_metrics: Dict[str, Any] = {
"avg_response_time": 0,
"total_errors": 0,
"success_rate": 100,
"requests_per_minute": 0,
}
self._last_request_time = 0
self._is_anthropic = is_anthropic
openai.api_key = self.api_key
if endpoint != "https://api.openai.com/v1":
openai.api_base = endpoint
if is_anthropic:
self.client = AsyncAnthropic(api_key=self.api_key)
else:
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:
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]:
"""Update data via OpenAI API."""
"""Update data via API."""
if self._question_queue.empty():
return self._responses
try:
question = await self._question_queue.get()
response_content = await self.hass.async_add_executor_job(
self._make_api_call, question
async with async_timeout.timeout(DEFAULT_TIMEOUT):
self._is_processing = True
priority, question_data = await self._question_queue.get()
question = question_data["question"]
params = question_data["params"]
try:
response_data = await self._make_api_call(
question,
model=params.get("model"),
temperature=params.get("temperature"),
max_tokens=params.get("max_tokens"),
system_prompt=params.get("system_prompt")
)
self._update_metrics(response_data)
self._responses[question] = {
"question": question,
"response": response_data["response"],
"error": None,
"timestamp": dt_util.utcnow(),
"model": response_data["model"],
"temperature": params.get("temperature", self.temperature),
"max_tokens": params.get("max_tokens", self.max_tokens),
"response_time": response_data.get("response_time"),
"tokens": response_data.get("tokens", 0),
"priority": priority
}
self._error_count = 0
self._is_ready = True
self._endpoint_status = "connected"
self._request_count += 1
self._tokens_used += response_data.get("tokens", 0)
self._last_request_time = time.time()
# Fire event for successful response
self.hass.bus.async_fire(f"{DOMAIN}_response_received", {
"question": question,
"model": response_data["model"],
"tokens": response_data.get("tokens", 0)
})
_LOGGER.debug("Response received for question: %s", question)
except Exception as err:
await self._handle_api_error(question, err)
finally:
self._is_processing = False
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
async def _handle_api_error(self, question: str, error: Exception) -> None:
"""Handle API errors with retry logic."""
self._error_count += 1
self._performance_metrics["total_errors"] += 1
error_msg = str(error)
if isinstance(error, AuthenticationError):
error_msg = "Authentication failed - invalid API key"
self._is_ready = False
self._endpoint_status = "auth_error"
elif isinstance(error, RateLimitError):
error_msg = "Rate limit exceeded"
self._is_rate_limited = True
self._endpoint_status = "rate_limited"
# Implement exponential backoff
await asyncio.sleep(RETRY_DELAY * (2 ** (self._error_count - 1)))
elif isinstance(error, APIError):
if "maintenance" in str(error).lower():
self._is_maintenance = True
self._endpoint_status = "maintenance"
error_msg = f"API error: {error}"
else:
self._endpoint_status = "error"
self._responses[question] = {
"question": question,
"response": None,
"error": error_msg,
"timestamp": dt_util.utcnow(),
"model": self.model,
"temperature": self.temperature,
"max_tokens": self.max_tokens
}
# Fire error event
self.hass.bus.async_fire(f"{DOMAIN}_error_occurred", {
"error_type": type(error).__name__,
"error_message": error_msg,
"question": question
})
_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:
raise ConfigEntryAuthFailed from err
except Exception as err:
_LOGGER.error("Error communicating with API: %s", err)
return self._responses
def _update_metrics(self, response_data: Dict[str, Any]) -> None:
"""Update performance metrics."""
response_time = response_data.get("response_time", 0)
current_avg = self._performance_metrics["avg_response_time"]
self._performance_metrics["avg_response_time"] = (
(current_avg * self._request_count + response_time) /
(self._request_count + 1)
)
def _make_api_call(self, question: str) -> str:
"""Make API call to OpenAI."""
total_requests = self._request_count + 1
self._performance_metrics["success_rate"] = (
(total_requests - self._performance_metrics["total_errors"]) /
total_requests * 100
)
# Calculate requests per minute
if self._last_request_time:
time_diff = time.time() - self._last_request_time
if time_diff > 0:
self._performance_metrics["requests_per_minute"] = 60 / time_diff
async def _make_api_call(
self,
question: str,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None
) -> Dict[str, Any]:
"""Make API call to the selected service."""
try:
messages = [{"role": "system", "content": self.system_prompt}] if self.system_prompt else []
messages.append({"role": "user", "content": question})
completion = openai.chat.completions.create(
model=self.model,
messages=messages,
temperature=self.temperature,
max_tokens=self.max_tokens,
)
return completion.choices[0].message.content
start_time = dt_util.utcnow()
if self._is_anthropic:
response = await self._make_anthropic_call(
question, model, temperature, max_tokens, system_prompt
)
else:
response = await self._make_openai_call(
question, model, temperature, max_tokens, system_prompt
)
response_time = (dt_util.utcnow() - start_time).total_seconds()
return {
**response,
"response_time": response_time
}
except Exception as err:
_LOGGER.error("Error in API call: %s", err)
raise
async def async_ask_question(self, question: str) -> None:
"""Add question to queue."""
await self._question_queue.put(question)
_LOGGER.debug(f"Question added to queue: {question}")
async def _make_anthropic_call(
self,
question: str,
model: Optional[str],
temperature: Optional[float],
max_tokens: Optional[int],
system_prompt: Optional[str]
) -> Dict[str, Any]:
"""Make API call to Anthropic."""
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": question})
completion = await self.client.messages.create(
model=model or self.model,
messages=messages,
temperature=temperature if temperature is not None else self.temperature,
max_tokens=max_tokens if max_tokens is not None else self.max_tokens,
)
return {
"response": completion.content[0].text,
"model": completion.model,
"tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0
}
async def _make_openai_call(
self,
question: str,
model: Optional[str],
temperature: Optional[float],
max_tokens: Optional[int],
system_prompt: Optional[str]
) -> Dict[str, Any]:
"""Make API call to OpenAI."""
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": question})
completion = await self.client.chat.completions.create(
model=model or self.model,
messages=messages,
temperature=temperature if temperature is not None else self.temperature,
max_tokens=max_tokens if max_tokens is not None else self.max_tokens,
)
return {
"response": completion.choices[0].message.content,
"model": completion.model,
"tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0
}
async def async_ask_question(
self,
question: str,
system_prompt: Optional[str] = None,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
priority: bool = False
) -> None:
"""Add question to queue with priority support."""
if not self._is_ready and self._error_count >= self._MAX_ERRORS:
_LOGGER.warning("Coordinator is not ready due to previous errors")
return
question_data = {
"question": question,
"params": {
"system_prompt": system_prompt,
"model": model,
"temperature": temperature,
"max_tokens": max_tokens
}
}
# Priority: 0 for high priority, 1 for normal
priority_level = 0 if priority else 1
try:
await self._question_queue.put((priority_level, question_data))
await self.async_refresh()
except asyncio.QueueFull:
_LOGGER.error("Question queue is full. Try again later.")
raise RuntimeError("Queue is full")
async def async_shutdown(self) -> None:
"""Shutdown the coordinator."""
try:
while not self._question_queue.empty():
try:
self._question_queue.get_nowait()
self._question_queue.task_done()
except asyncio.QueueEmpty:
break
if hasattr(self.client, 'close'):
await self.client.close()
self._is_ready = False
self._endpoint_status = "disconnected"
# Final metrics update
self._update_final_metrics()
except Exception as err:
_LOGGER.error("Error during shutdown: %s", err)
def _update_final_metrics(self) -> None:
"""Update final metrics before shutdown."""
if self._request_count > 0:
self._performance_metrics["final_success_rate"] = (
(self._request_count - self._performance_metrics["total_errors"]) /
self._request_count * 100
)
self._performance_metrics["total_requests"] = self._request_count
self._performance_metrics["total_tokens"] = self._tokens_used
@property
def performance_metrics(self) -> Dict[str, Any]:
"""Return current performance metrics."""
return self._performance_metrics
@property
def queue_size(self) -> int:
"""Return current queue size."""
return self._question_queue.qsize()
@property
def is_queue_full(self) -> bool:
"""Return whether queue is full."""
return self._question_queue.full()
@property
def is_ready(self) -> bool:
"""Return if coordinator is ready."""
return self._is_ready and self._error_count < self._MAX_ERRORS
@property
def is_processing(self) -> bool:
"""Return if coordinator is processing."""
return self._is_processing
@property
def is_rate_limited(self) -> bool:
"""Return if coordinator is rate limited."""
return self._is_rate_limited
@property
def is_maintenance(self) -> bool:
"""Return if API is in maintenance."""
return self._is_maintenance
@property
def error_count(self) -> int:
"""Return current error count."""
return self._error_count
@property
def request_count(self) -> int:
"""Return total request count."""
return self._request_count
@property
def tokens_used(self) -> int:
"""Return total tokens used."""
return self._tokens_used
@property
def api_version(self) -> str:
"""Return API version."""
return self._api_version
@property
def endpoint_status(self) -> str:
"""Return endpoint status."""
return self._endpoint_status
@property
def responses(self) -> Dict[str, Any]:
"""Return all responses."""
return self._responses
@property
def last_response(self) -> Optional[Dict[str, Any]]:
"""Return the last response."""
if not self._responses:
return None
return next(iter(self._responses.values()))
def reset_error_count(self) -> None:
"""Reset error counter."""
self._error_count = 0
self._is_rate_limited = False
self._is_maintenance = False
if not self._is_ready:
self._is_ready = True
self._endpoint_status = "connected"
async def clear_queue(self) -> None:
"""Clear the question queue."""
try:
while not self._question_queue.empty():
try:
self._question_queue.get_nowait()
self._question_queue.task_done()
except asyncio.QueueEmpty:
break
except Exception as err:
_LOGGER.error("Error clearing queue: %s", err)
async def clear_history(self) -> None:
"""Clear response history."""
self._responses.clear()
await self.async_refresh()
def clear_history(self) -> None:
"""Clear the stored question and response history."""
self._responses.clear()
_LOGGER.info("History cleared.")
def get_response(self, question: str) -> Optional[Dict[str, Any]]:
"""Get specific response by question."""
return self._responses.get(question)
def get_history(self, limit: int = 10) -> Dict[str, Any]:
"""Get the history of questions and responses."""
return {"history": list(self._responses.values())[-limit:]}
def get_recent_responses(self, limit: int = 10) -> List[Dict[str, Any]]:
"""Get most recent responses."""
return list(sorted(
self._responses.values(),
key=lambda x: x["timestamp"],
reverse=True
))[:limit]
def set_system_prompt(self, prompt: str) -> None:
"""Set a system prompt that will be used for all future questions."""
self.system_prompt = prompt
_LOGGER.info(f"System prompt set: {prompt}")
async def retry_failed_requests(self) -> None:
"""Retry failed requests."""
failed_requests = [
(q, r) for q, r in self._responses.items()
if r.get("error") is not None
]
for question, response in failed_requests:
await self.async_ask_question(
question,
system_prompt=response.get("system_prompt"),
model=response.get("model"),
temperature=response.get("temperature"),
max_tokens=response.get("max_tokens"),
priority=True
)
def update_system_prompt(self, new_prompt: str) -> None:
"""Update system prompt."""
self.system_prompt = new_prompt
_LOGGER.info("System prompt updated")
async def health_check(self) -> Dict[str, Any]:
"""Perform health check."""
health_status = {
"is_ready": self.is_ready,
"is_processing": self.is_processing,
"is_rate_limited": self.is_rate_limited,
"is_maintenance": self.is_maintenance,
"error_count": self.error_count,
"endpoint_status": self.endpoint_status,
"queue_size": self.queue_size,
"request_count": self.request_count,
"tokens_used": self.tokens_used,
"performance_metrics": self.performance_metrics
}
return health_status
async def _handle_timeout_error(self) -> None:
"""Handle timeout errors."""
self._error_count += 1
self._endpoint_status = "timeout"
self._performance_metrics["total_errors"] += 1
if not self._question_queue.empty():
await self.clear_queue()
# Fire timeout event
self.hass.bus.async_fire(f"{DOMAIN}_timeout_error", {
"error_count": self._error_count,
"endpoint_status": self._endpoint_status
})
def export_metrics(self) -> Dict[str, Any]:
"""Export all metrics and statistics."""
return {
"performance": self._performance_metrics,
"requests": {
"total": self._request_count,
"successful": self._request_count - self._performance_metrics["total_errors"],
"failed": self._performance_metrics["total_errors"]
},
"tokens": {
"total_used": self._tokens_used,
"average_per_request": self._tokens_used / self._request_count if self._request_count > 0 else 0
},
"status": {
"is_ready": self.is_ready,
"endpoint_status": self._endpoint_status,
"error_count": self._error_count
},
"queue": {
"size": self.queue_size,
"is_full": self.is_queue_full
}
}
async def validate_model(self, model: str) -> bool:
"""Validate if model is supported."""
return model in SUPPORTED_MODELS
async def estimate_tokens(self, text: str) -> int:
"""Estimate token count for text."""
# Простая оценка: примерно 4 символа на токен
return len(text) // 4
def get_rate_limit_info(self) -> Dict[str, Any]:
"""Get rate limit information."""
return {
"is_rate_limited": self._is_rate_limited,
"retry_after": RETRY_DELAY * (2 ** (self._error_count - 1)) if self._is_rate_limited else 0
}
async def optimize_queue(self) -> None:
"""Optimize queue by removing duplicate requests."""
if self._question_queue.empty():
return
seen_questions = set()
optimized_queue = asyncio.PriorityQueue(maxsize=MAX_QUEUE_SIZE)
while not self._question_queue.empty():
try:
priority, question_data = self._question_queue.get_nowait()
question = question_data["question"]
if question not in seen_questions:
seen_questions.add(question)
await optimized_queue.put((priority, question_data))
self._question_queue.task_done()
except asyncio.QueueEmpty:
break
self._question_queue = optimized_queue
+39 -12
View File
@@ -1,15 +1,42 @@
{
"domain": "ha-text-ai",
"name": "HA text AI",
"config_flow": true,
"documentation": "https://github.com/smkrv/ha-text-ai",
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
"requirements": ["openai>=1.0.0"],
"ssdp": [],
"zeroconf": [],
"homekit": {},
"dependencies": [],
"domain": "ha_text_ai",
"name": "HA Text AI",
"codeowners": ["@smkrv"],
"version": "1.0.0",
"iot_class": "cloud_polling"
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/smkrv/ha-text-ai",
"homepage": "https://github.com/smkrv/ha-text-ai",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
"requirements": [
"openai>=1.12.0",
"anthropic>=0.8.0",
"aiohttp>=3.8.0",
"async-timeout>=4.0.0"
],
"version": "2.0.0",
"homeassistant": "2024.1.0",
"quality_scale": "silver",
"integration_type": "service",
"translations": [
"en"
],
"zeroconf": [],
"ssdp": [],
"usb": [],
"bluetooth": [],
"mqtt": [],
"loggers": ["custom_components.ha_text_ai"],
"after_dependencies": ["http"],
"title": "Text AI Assistant",
"description": "AI-powered text assistant supporting multiple models including GPT and Claude",
"render_readme": true,
"tags": [
"ai",
"automation",
"chat",
"assistant",
"openai",
"claude"
]
}
+196 -19
View File
@@ -1,16 +1,54 @@
"""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.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN, ATTR_QUESTION, ATTR_RESPONSE, ATTR_LAST_UPDATED
from .const import (
DOMAIN,
ATTR_QUESTION,
ATTR_RESPONSE,
ATTR_LAST_UPDATED,
ATTR_MODEL,
ATTR_TEMPERATURE,
ATTR_MAX_TOKENS,
ATTR_TOTAL_RESPONSES,
ATTR_SYSTEM_PROMPT,
ATTR_QUEUE_SIZE,
ATTR_API_STATUS,
ATTR_ERROR_COUNT,
ATTR_LAST_ERROR,
ATTR_RESPONSE_TIME,
ATTR_API_VERSION,
ATTR_ENDPOINT_STATUS,
ATTR_REQUEST_COUNT,
ATTR_TOKENS_USED,
ENTITY_ICON,
ENTITY_ICON_ERROR,
ENTITY_ICON_PROCESSING,
STATE_READY,
STATE_PROCESSING,
STATE_ERROR,
STATE_DISCONNECTED,
STATE_RATE_LIMITED,
STATE_INITIALIZING,
STATE_MAINTENANCE,
)
from .coordinator import HATextAICoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
@@ -20,9 +58,14 @@ async def async_setup_entry(
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([HATextAISensor(coordinator, entry)], True)
class HATextAISensor(CoordinatorEntity, SensorEntity):
"""HA text AI Sensor."""
_attr_has_entity_name = True
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_device_class = SensorDeviceClass.TIMESTAMP
def __init__(
self,
coordinator: HATextAICoordinator,
@@ -32,27 +75,161 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
super().__init__(coordinator)
self._config_entry = config_entry
self._attr_unique_id = f"{config_entry.entry_id}"
self._attr_name = "HA text AI"
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_name = "Last Response"
self._attr_suggested_display_precision = 0
self._error_count = 0
self._last_error = None
self._state = STATE_INITIALIZING
self._attr_device_info = {
"identifiers": {(DOMAIN, self._attr_unique_id)},
"name": "HA Text AI",
"manufacturer": "Community",
"model": coordinator.model,
"sw_version": coordinator.api_version,
}
@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
def state(self) -> StateType:
"""Return the state of the sensor."""
if self.coordinator.data:
return "Ready" # Assuming "Ready" is a valid state, you might want to return something meaningful, like the last response time.
return "Not Ready"
if not self.coordinator.data or not self.coordinator.last_update_success:
return None
try:
if self.coordinator.data and isinstance(self.coordinator.data, dict):
last_update = self.coordinator.data.get("last_update")
if isinstance(last_update, datetime):
return dt_util.as_local(last_update)
return last_update
return None
except Exception as err:
_LOGGER.error("Error getting state: %s", err, exc_info=True)
return None
@property
def extra_state_attributes(self) -> Optional[Dict[str, Any]]:
def extra_state_attributes(self) -> Dict[str, Any]:
"""Return entity specific state attributes."""
if not self.coordinator.data:
return None
keys = list(self.coordinator.data.keys())
values = list(self.coordinator.data.values())
last_question = keys[-1]
last_response = values[-1]
return {
ATTR_QUESTION: last_question,
ATTR_RESPONSE: last_response,
ATTR_LAST_UPDATED: self.coordinator.last_update_success_time,
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,
ATTR_API_VERSION: self.coordinator.api_version,
ATTR_ENDPOINT_STATUS: self.coordinator.endpoint_status,
ATTR_REQUEST_COUNT: self.coordinator.request_count,
ATTR_TOKENS_USED: self.coordinator.tokens_used,
}
if not self.coordinator.data:
return attributes
try:
history = list(self.coordinator._responses.items())
if history:
last_question, last_data = history[-1]
if isinstance(last_data, dict):
last_response = last_data.get("response", "")
last_updated = last_data.get("timestamp") or self.coordinator.data.get("last_update")
response_time = last_data.get("response_time")
model = last_data.get("model", self.coordinator.model)
temperature = last_data.get("temperature", self.coordinator.temperature)
max_tokens = last_data.get("max_tokens", self.coordinator.max_tokens)
error = last_data.get("error")
if error:
self._last_error = error
self._state = STATE_ERROR
else:
last_response = str(last_data)
last_updated = self.coordinator.data.get("last_update")
response_time = None
model = self.coordinator.model
temperature = self.coordinator.temperature
max_tokens = self.coordinator.max_tokens
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),
ATTR_MODEL: model,
ATTR_TEMPERATURE: temperature,
ATTR_MAX_TOKENS: max_tokens,
})
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:
if self.coordinator._is_ready:
if self.coordinator._is_processing:
self._state = STATE_PROCESSING
elif self.coordinator._is_rate_limited:
self._state = STATE_RATE_LIMITED
elif self.coordinator._is_maintenance:
self._state = STATE_MAINTENANCE
else:
self._state = STATE_READY
else:
self._state = STATE_DISCONNECTED
else:
self._state = STATE_DISCONNECTED
# Обновляем счетчик ошибок только если статус изменился на ошибку
if self._state == STATE_ERROR:
self._error_count += 1
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()
async def async_reset_error_count(self) -> None:
"""Reset the error counter."""
self._error_count = 0
self._last_error = None
self.async_write_ha_state()
+143 -22
View File
@@ -1,66 +1,135 @@
# Service to ask a question
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.
Supports various AI models with different capabilities and pricing.
fields:
question:
name: Question
description: The question or prompt to send to the AI
description: >-
Your question or prompt for the AI assistant. Be specific and clear for better results.
You can ask about home automation, technical advice, or general questions.
For complex queries, consider breaking them into smaller parts.
The system will maintain conversation context for follow-up questions.
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:
text:
multiline: true
type: text
model:
name: Model
description: Override the default model for this question (optional)
description: >-
Select an AI model to use (optional, overrides default setting).
Different models have different capabilities, context limits, and response characteristics:
- GPT-3.5 Turbo: Fast, efficient, good for most tasks
- GPT-4: More capable, better reasoning, slower
- Claude-3: Advanced capabilities, longer context
Note: More capable models may have longer response times and higher API costs.
required: false
example: "gpt-4"
example: "gpt-3.5-turbo"
default: "gpt-3.5-turbo"
selector:
select:
custom_value: true
options:
- "gpt-3.5-turbo"
- "gpt-4"
- "gpt-4-32k"
- label: "GPT-3.5 Turbo (Fast & Efficient)"
value: "gpt-3.5-turbo"
- 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"
- label: "Claude-3 Sonnet (Balanced)"
value: "claude-3-sonnet"
- label: "Claude-3 Opus (Most Advanced)"
value: "claude-3-opus"
mode: dropdown
temperature:
name: Temperature
description: Control randomness in the response (0.0-1.0, lower is more focused)
description: >-
Controls response creativity (0.0-2.0):
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
default: 0.7
selector:
number:
min: 0.0
max: 1.0
max: 2.0
step: 0.1
mode: slider
unit_of_measurement: ""
max_tokens:
name: Max Tokens
description: Maximum length of the response
description: >-
Maximum length of the response (1-4096 tokens).
Recommended ranges:
- Short (256-512): Quick answers, status updates
- Medium (512-1024): Detailed explanations
- Long (1024-4096): Complex analysis
Note: Token limits vary by model. Actual length may be shorter.
required: false
default: 1000
selector:
number:
min: 1
max: 4000
max: 4096
step: 1
mode: box
system_prompt:
name: System Prompt
description: >-
Optional system prompt to set context for this specific question.
This will temporarily override the default system prompt.
Use this to specify expertise areas, response format, or special instructions.
required: false
example: "You are a home automation expert focused on energy efficiency and security"
selector:
text:
multiline: true
# Service to clear response history
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 and configuration will be preserved.
fields: {}
# Service to get response history
get_history:
name: Get History
description: Get the history of questions and responses
description: >-
Retrieve recent conversation history, including:
- Questions and responses
- Timestamps and response times
- Models used and token counts
- Temperature and other settings
Results are ordered from newest to oldest.
fields:
limit:
name: Limit
description: Maximum number of history items to return
description: >-
Number of most recent conversations to return (1-100).
Higher values return more history but may take longer to process.
Default: 10 conversations
required: false
default: 10
selector:
@@ -68,17 +137,69 @@ get_history:
min: 1
max: 100
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:
custom_value: true
options:
- label: "All Models"
value: ""
- label: "GPT-3.5 Turbo"
value: "gpt-3.5-turbo"
- label: "GPT-4"
value: "gpt-4"
- label: "GPT-4 Turbo"
value: "gpt-4-1106-preview"
- label: "Claude-3 Sonnet"
value: "claude-3-sonnet"
- label: "Claude-3 Opus"
value: "claude-3-opus"
mode: dropdown
start_date:
name: Start Date
description: >-
Optional start date for filtering history.
Format: YYYY-MM-DD
required: false
selector:
datetime:
type: date
# Service to set system prompt
set_system_prompt:
name: Set System Prompt
description: Set a system prompt that will be used for all future questions
description: >-
Set default system behavior instructions for all future conversations.
This defines how the AI should behave and respond to questions.
The prompt will be used for all models unless overridden per question.
fields:
prompt:
name: System Prompt
description: The system prompt to set
description: >-
Instructions that define how the AI should behave and respond.
Be specific about:
- Desired expertise and knowledge areas
- Response tone and style
- Output format preferences
- Special handling instructions
Maximum length: 1000 characters
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:
text:
multiline: true
+140 -31
View File
@@ -1,38 +1,147 @@
{
"config": {
"step": {
"user": {
"title": "Set up HA text AI",
"description": "Set up your OpenAI integration",
"data": {
"api_key": "API Key",
"model": "Model",
"temperature": "Temperature",
"max_tokens": "Max Tokens",
"api_endpoint": "API Endpoint",
"request_interval": "Request Interval (seconds)"
"config": {
"step": {
"user": {
"title": "Set up HA Text AI",
"description": "Configure your AI integration for various providers (OpenAI, Anthropic, etc.). Different models have different capabilities and pricing.",
"data": {
"api_key": "API key for authentication (required)",
"model": "AI model to use (e.g., gpt-3.5-turbo, gpt-4, claude-3-sonnet, claude-3-opus)",
"temperature": "Response creativity (0-2, lower = more focused and consistent)",
"max_tokens": "Maximum response length (1-4096 tokens)",
"api_endpoint": "Custom API endpoint URL (default varies by provider)",
"request_interval": "Minimum time between requests in seconds (min: 0.1)",
"system_prompt": "Default instructions for AI behavior and expertise"
}
}
},
"error": {
"invalid_auth": "Authentication failed - check your API key",
"invalid_api_key": "Invalid API key - please verify your credentials",
"cannot_connect": "Connection failed - check endpoint and network status",
"invalid_model": "Model unavailable or not supported by the API",
"rate_limit": "Rate limit exceeded - please reduce request frequency",
"context_length": "Input exceeds maximum context length for model",
"api_error": "API service error - check provider status",
"timeout": "Request timeout - server not responding",
"queue_full": "Request queue full - try again later",
"invalid_prompt": "Invalid system prompt format or length",
"invalid_url_format": "Invalid API endpoint URL format",
"invalid_input": "Invalid configuration parameters",
"unknown": "Unexpected error - check logs for details"
}
}
},
"error": {
"auth": "API key is invalid.",
"cannot_connect": "Failed to connect to API.",
"unknown": "Unexpected error occurred."
"options": {
"step": {
"init": {
"title": "HA Text AI Settings",
"description": "Adjust your AI integration parameters and behavior",
"data": {
"model": "Select AI model for responses (capabilities vary)",
"temperature": "Response creativity (0-2, affects variation)",
"max_tokens": "Maximum response length in tokens",
"request_interval": "Minimum seconds between requests (rate limiting)",
"system_prompt": "Default AI behavior and expertise instructions",
"timeout": "Request timeout in seconds (default: 30)",
"retry_count": "Number of retry attempts for failed requests",
"queue_size": "Maximum pending requests in queue"
}
}
}
},
"abort": {
"already_configured": "Device is already configured"
}
},
"options": {
"step": {
"init": {
"title": "HA text AI Options",
"data": {
"temperature": "Temperature",
"max_tokens": "Max Tokens",
"request_interval": "Request Interval (seconds)"
"services": {
"ask_question": {
"name": "Ask Question",
"description": "Send a question to the AI model and get a detailed response. Supports context awareness for follow-up questions.",
"fields": {
"question": {
"name": "Question",
"description": "Your question or prompt for the AI model. Be specific for better results."
},
"system_prompt": {
"name": "System Prompt",
"description": "Optional behavior instructions for this specific question"
},
"model": {
"name": "Model",
"description": "Optional specific AI model for this request (overrides default)"
},
"temperature": {
"name": "Temperature",
"description": "Optional creativity setting (0-2, affects response variation)"
},
"max_tokens": {
"name": "Max Tokens",
"description": "Optional maximum response length (1-4096 tokens)"
}
}
},
"clear_history": {
"name": "Clear History",
"description": "Delete all stored conversation history, responses, and metadata"
},
"get_history": {
"name": "Get History",
"description": "Retrieve conversation history with detailed metadata including timestamps",
"fields": {
"limit": {
"name": "Limit",
"description": "Maximum number of conversations to return (1-100)"
},
"filter_model": {
"name": "Filter Model",
"description": "Show only responses from a specific model"
},
"start_date": {
"name": "Start Date",
"description": "Filter conversations from this date (YYYY-MM-DD)"
}
}
},
"set_system_prompt": {
"name": "Set System Prompt",
"description": "Update default AI behavior and expertise instructions",
"fields": {
"prompt": {
"name": "System Prompt",
"description": "Instructions for AI behavior, expertise, and response style"
}
}
}
},
"state": {
"ready": "Ready for requests",
"processing": "Processing request",
"error": "Error occurred",
"disconnected": "API disconnected",
"rate_limited": "Rate limit reached",
"initializing": "Starting up",
"retrying": "Retrying request",
"queued": "Request queued"
},
"entity": {
"sensor": {
"status": {
"name": "AI Status",
"state": {
"ready": "Ready",
"processing": "Processing",
"error": "Error",
"disconnected": "Disconnected",
"rate_limited": "Rate Limited",
"initializing": "Initializing",
"retrying": "Retrying",
"queued": "Queued"
}
},
"last_response": {
"name": "Last Response",
"state": {
"success": "Success",
"error": "Error",
"timeout": "Timeout"
}
}
}
}
}
}
}
BIN
View File
Binary file not shown.
+4 -2
View File
@@ -2,6 +2,8 @@
"name": "HA text AI",
"render_readme": true,
"domains": ["sensor"],
"homeassistant": "2023.8.0",
"icon": "mdi:brain"
"homeassistant": "2024.11.0",
"icon": "mdi:brain",
"version": "2.0.0",
"documentation": "https://github.com/smkrv/ha-text-ai"
}
+17
View File
@@ -0,0 +1,17 @@
```
ha-text-ai/
├── custom_components/
│ └── ha_text_ai/
│ ├── __init__.py
│ ├── config_flow.py
│ ├── coordinator.py
│ ├── manifest.json
│ ├── sensor.py
│ ├── services.yaml
│ └── const.py
└── strings/
├── en.json
└── ru.json
```
-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]