mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-23 07:33:58 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
976f4f16a3 | ||
|
|
2bdef2b494 | ||
|
|
42324a793b | ||
|
|
30aa894634 | ||
|
|
398b2550a9 | ||
|
|
f1deaa2014 | ||
|
|
f4b0ce902e | ||
|
|
d899144149 | ||
|
|
5d49b4a40b | ||
|
|
1a84727cf1 | ||
|
|
d3c7e25202 | ||
|
|
5e82b9669c | ||
|
|
103f9d59e9 | ||
|
|
6ad67a5acf | ||
|
|
12e5778a1c | ||
|
|
5c85cba606 | ||
|
|
63d28e12d6 | ||
|
|
605c73c6c4 | ||
|
|
9d54f88520 | ||
|
|
34e93e0045 | ||
|
|
34bfd7dfe1 | ||
|
|
e3487a48cb | ||
|
|
72a98d0076 |
@@ -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
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
name: Validate
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 0 * * *"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.x"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
- name: Run tests
|
|
||||||
run: |
|
|
||||||
pytest
|
|
||||||
@@ -1,145 +1,263 @@
|
|||||||
# 🤖 HA text AI Integration for Home Assistant
|
# 🤖 HA Text AI for Home Assistant
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|

|
||||||

|

|
||||||

|

|
||||||

|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://github.com/hacs/integration)
|
[](https://github.com/hacs/integration)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.7",
|
||||||
"homekit": {},
|
"zeroconf": []
|
||||||
"dependencies": [],
|
|
||||||
"codeowners": ["@smkrv"],
|
|
||||||
"version": "1.0.0",
|
|
||||||
"iot_class": "cloud_polling"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,60 @@ 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
|
name: Set System Prompt
|
||||||
description: Set a system prompt that will be used for all future questions
|
description: >-
|
||||||
|
Configure the AI's behavior by setting a system prompt.
|
||||||
|
This affects how the AI interprets and responds to all future questions.
|
||||||
|
The prompt will persist until changed or cleared.
|
||||||
fields:
|
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
|
||||||
|
max_length: 1000
|
||||||
|
|
||||||
|
clear_prompt:
|
||||||
|
name: Clear Existing Prompt
|
||||||
|
description: >-
|
||||||
|
Set to true to remove the current system prompt before applying the new one.
|
||||||
|
This ensures no conflicting instructions remain.
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean: {}
|
||||||
|
|||||||
@@ -1,38 +1,61 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"option": {
|
||||||
"user": {
|
"api_key": {
|
||||||
"title": "Set up HA text AI",
|
"name": "API Key",
|
||||||
"description": "Set up your OpenAI integration",
|
"description": "Your OpenAI API key"
|
||||||
"data": {
|
},
|
||||||
"api_key": "API Key",
|
"model": {
|
||||||
"model": "Model",
|
"name": "Model",
|
||||||
"temperature": "Temperature",
|
"description": "AI model to use for responses"
|
||||||
"max_tokens": "Max Tokens",
|
},
|
||||||
"api_endpoint": "API Endpoint",
|
"temperature": {
|
||||||
"request_interval": "Request Interval (seconds)"
|
"name": "Temperature",
|
||||||
|
"description": "Temperature for response generation (0-2)"
|
||||||
|
},
|
||||||
|
"max_tokens": {
|
||||||
|
"name": "Max Tokens",
|
||||||
|
"description": "Maximum tokens in response (1-4096)"
|
||||||
|
},
|
||||||
|
"api_endpoint": {
|
||||||
|
"name": "API Endpoint",
|
||||||
|
"description": "API endpoint URL"
|
||||||
|
},
|
||||||
|
"request_interval": {
|
||||||
|
"name": "Request Interval",
|
||||||
|
"description": "Minimum time between API requests (seconds)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": "Temperature",
|
||||||
|
"max_tokens": "Max Tokens",
|
||||||
|
"request_interval": "Request Interval"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"auth": "API key is invalid.",
|
"invalid_api_key": "Invalid API key",
|
||||||
"cannot_connect": "Failed to connect to API.",
|
"cannot_connect": "Cannot connect to the API",
|
||||||
"unknown": "Unexpected error occurred."
|
"unknown_error": "Unknown error",
|
||||||
|
"invalid_model": "Invalid model",
|
||||||
|
"rate_limit_exceeded": "Rate limit exceeded",
|
||||||
|
"context_length_exceeded": "Context length exceeded",
|
||||||
|
"api_error": "API error",
|
||||||
|
"timeout_error": "Timeout error",
|
||||||
|
"queue_full": "Queue full",
|
||||||
|
"invalid_prompt": "Invalid prompt"
|
||||||
},
|
},
|
||||||
"abort": {
|
"state": {
|
||||||
"already_configured": "Device is already configured"
|
"ready": "Ready",
|
||||||
|
"processing": "Processing",
|
||||||
|
"error": "Error",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"rate_limited": "Rate limited",
|
||||||
|
"initializing": "Initializing"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"ask_question": "Ask Question",
|
||||||
|
"clear_history": "Clear History",
|
||||||
|
"get_history": "Get History",
|
||||||
|
"set_system_prompt": "Set System Prompt"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"step": {
|
|
||||||
"init": {
|
|
||||||
"title": "HA text AI Options",
|
|
||||||
"data": {
|
|
||||||
"temperature": "Temperature",
|
|
||||||
"max_tokens": "Max Tokens",
|
|
||||||
"request_interval": "Request Interval (seconds)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"option": {
|
||||||
|
"api_key": {
|
||||||
|
"name": "API ключ",
|
||||||
|
"description": "Ваш API ключ OpenAI"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "Модель",
|
||||||
|
"description": "Модель AI для генерации ответов"
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"name": "Температура",
|
||||||
|
"description": "Температура для генерации ответов (0-2)"
|
||||||
|
},
|
||||||
|
"max_tokens": {
|
||||||
|
"name": "Максимальное количество токенов",
|
||||||
|
"description": "Максимальное количество токенов в ответе (1-4096)"
|
||||||
|
},
|
||||||
|
"api_endpoint": {
|
||||||
|
"name": "Конечная точка API",
|
||||||
|
"description": "URL конечной точки API"
|
||||||
|
},
|
||||||
|
"request_interval": {
|
||||||
|
"name": "Интервал запросов",
|
||||||
|
"description": "Минимальное время между запросами к API в секундах"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": "Температура",
|
||||||
|
"max_tokens": "Максимальное количество токенов",
|
||||||
|
"request_interval": "Интервал запросов"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"invalid_api_key": "Неверный API ключ",
|
||||||
|
"cannot_connect": "Не удается подключиться к API",
|
||||||
|
"unknown_error": "Неизвестная ошибка",
|
||||||
|
"invalid_model": "Неверная модель",
|
||||||
|
"rate_limit_exceeded": "Превышен лимит запросов",
|
||||||
|
"context_length_exceeded": "Превышена длина контекста",
|
||||||
|
"api_error": "Ошибка API",
|
||||||
|
"timeout_error": "Время ожидания истекло",
|
||||||
|
"queue_full": "Очередь полна",
|
||||||
|
"invalid_prompt": "Неверный запрос"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"ready": "Готово",
|
||||||
|
"processing": "Обработка",
|
||||||
|
"error": "Ошибка",
|
||||||
|
"disconnected": "Отключено",
|
||||||
|
"rate_limited": "Ограниченный по скорости",
|
||||||
|
"initializing": "Инициализация"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"ask_question": "Задать вопрос",
|
||||||
|
"clear_history": "Очистить историю",
|
||||||
|
"get_history": "Получить историю",
|
||||||
|
"set_system_prompt": "Установить системный запрос"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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.7",
|
||||||
|
"documentation": "https://github.com/smkrv/ha-text-ai"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Common fixtures for tests."""
|
|
||||||
import pytest
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def hass() -> HomeAssistant:
|
|
||||||
"""Return a Home Assistant instance for testing."""
|
|
||||||
return HomeAssistant()
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Tests for the HA text AI integration."""
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from custom_components.ha_text_ai.const import DOMAIN
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.setup import async_setup_component
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_setup_entry() -> AsyncMock:
|
|
||||||
"""Override async_setup_entry."""
|
|
||||||
with patch(
|
|
||||||
"custom_components.ha_text_ai.async_setup_entry",
|
|
||||||
return_value=True,
|
|
||||||
) as mock_setup_entry:
|
|
||||||
yield mock_setup_entry
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_coordinator() -> AsyncMock:
|
|
||||||
"""Override coordinator."""
|
|
||||||
with patch(
|
|
||||||
"custom_components.ha_text_ai.coordinator.HATextAICoordinator",
|
|
||||||
return_value=AsyncMock(),
|
|
||||||
) as mock_coordinator:
|
|
||||||
yield mock_coordinator
|
|
||||||
|
|
||||||
async def test_async_setup(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
|
||||||
"""Test the initial setup."""
|
|
||||||
assert await async_setup_component(hass, DOMAIN, {
|
|
||||||
DOMAIN: {
|
|
||||||
"api_key": "test_key",
|
|
||||||
"model": "gpt-3.5-turbo",
|
|
||||||
"temperature": 0.7,
|
|
||||||
"max_tokens": 1000,
|
|
||||||
"api_endpoint": "https://api.openai.com/v1",
|
|
||||||
"request_interval": 1.0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert DOMAIN in hass.data
|
|
||||||
|
|
||||||
async def test_async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_coordinator: AsyncMock
|
|
||||||
) -> None:
|
|
||||||
"""Test setup entry."""
|
|
||||||
entry = MockConfigEntry(
|
|
||||||
domain=DOMAIN,
|
|
||||||
data={
|
|
||||||
"api_key": "test_key",
|
|
||||||
"model": "gpt-3.5-turbo",
|
|
||||||
"temperature": 0.7,
|
|
||||||
"max_tokens": 1000,
|
|
||||||
"api_endpoint": "https://api.openai.com/v1",
|
|
||||||
"request_interval": 1.0
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entry.add_to_hass(hass)
|
|
||||||
|
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert len(mock_coordinator.mock_calls) == 1
|
|
||||||
assert DOMAIN in hass.data
|
|
||||||
assert entry.entry_id in hass.data[DOMAIN]
|
|
||||||
Reference in New Issue
Block a user