new version

This commit is contained in:
Sergey Makarov
2024-11-18 00:43:28 +03:00
parent a7a945e6e1
commit 9cda11ad40
19 changed files with 603 additions and 892 deletions
+32
View File
@@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Home Assistant (please complete the following information):**
- Version: [e.g. 2023.11.0]
- Installation type: [e.g. Home Assistant OS, Container, Core]
- Browser [e.g. chrome, safari]
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+2 -2
View File
@@ -8,7 +8,7 @@ on:
jobs:
validate:
runs-on: "ubuntu-latest"
runs-on: ubuntu-latest
steps:
- uses: "actions/checkout@v3"
- uses: actions/checkout@v3
- uses: home-assistant/actions/hassfest@master
+16 -6
View File
@@ -1,4 +1,4 @@
name: HACS Validation
name: Validate
on:
push:
@@ -8,10 +8,20 @@ on:
jobs:
validate:
runs-on: "ubuntu-latest"
runs-on: ubuntu-latest
steps:
- uses: "actions/checkout@v3"
- name: HACS validation
uses: "hacs/action@main"
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
category: "integration"
python-version: "3.x"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pylint
- name: Run tests
run: |
pytest
- name: Lint with pylint
run: |
pylint custom_components/ha_text_ai/*.py
+47 -109
View File
@@ -1,140 +1,78 @@
# HA Text AI Integration
# HA text AI
A Home Assistant integration that provides a bridge to OpenAI-compatible API services with automatic Text Helper management.
This is a Home Assistant custom integration that allows you to interact with OpenAI's API and compatible endpoints to generate AI responses.
## Features
- Easy integration with OpenAI-compatible APIs
- Automatic Text Helper creation for responses
- Support for custom API endpoints
- Built-in rate limiting and queue management
- Blueprint for easy automation setup
- Supports responses up to 65,536 characters
- Configure all settings through the Home Assistant UI
- Support for OpenAI API and compatible endpoints
- Configurable parameters (temperature, max tokens, etc.)
- Request queue system to prevent API rate limiting
- Long response storage
- Multiple parallel questions support
- Real-time updates
## Installation
### HACS (Recommended)
1. Open HACS in your Home Assistant instance
### HACS Installation
1. Go to HACS in your Home Assistant installation
2. Click on "Integrations"
3. Click the three dots in the top right corner
4. Select "Custom repositories"
5. Add this repository URL and select "Integration" as the category
6. Click "Install"
7. Restart Home Assistant
3. Click the "+" button
4. Search for "HA text AI"
5. Click "Install"
6. Restart Home Assistant
### Manual Installation
1. Copy the `custom_components/ha_text_ai` directory to your Home Assistant's `custom_components` directory
2. Restart Home Assistant
## Configuration
1. Go to Settings -> Devices & Services
2. Click "Add Integration"
3. Search for "HA Text AI"
4. Enter your configuration:
- API Key: Your OpenAI API key
- API Base URL (optional): Custom endpoint for OpenAI-compatible APIs
- Request Interval: Minimum time between requests (in seconds)
1. Go to Configuration -> Integrations
2. Click "+" to add a new integration
3. Search for "HA text AI"
4. Enter your OpenAI API key and configure other settings
## Usage
### Service Call
You can call the integration using the service `ha_text_ai.text_ai_call`:
### Service Calls
You can use the `ha_text_ai.ask_question` service to ask questions:
```yaml
service: ha_text_ai.text_ai_call
service: ha_text_ai.ask_question
data:
prompt: "Tell me a joke about home automation"
response_id: "automation_joke" # Optional, defaults to "default"
model: "gpt-3.5-turbo" # Optional
temperature: 0.7 # Optional
max_tokens: 150 # Optional
top_p: 1.0 # Optional
frequency_penalty: 0.0 # Optional
presence_penalty: 0.0 # Optional
question: "What is the weather like today?"
```
### Using the Blueprint
1. Go to Settings -> Automations & Scenes
2. Click the "Add Blueprint" button
3. Import this URL: `https://github.com/smkrv/ha-text-ai/blob/main/blueprints/automation/ha_text_ai_request.yaml`, [copy link](https://github.com/smkrv/ha-text-ai/blob/main/blueprints/automation/ha_text_ai_request.yaml)
4. Create a new automation using the blueprint
5. Configure the trigger and prompt template
### Accessing Responses
Responses are automatically stored in Text Helpers with the format `input_text.text_ai_response_[response_id]`
Example template to access a response:
### Automation Example
```yaml
{{ states('input_text.text_ai_response_automation_joke') }}
automation:
- alias: "Daily Weather Question"
trigger:
platform: time
at: "07:00:00"
action:
- service: ha_text_ai.ask_question
data:
question: "What's the weather forecast for today?"
```
## Blueprint Configuration Example
## Configuration Options
```yaml
trigger_entity: sensor.temperature
prompt_template: "Tell me a joke about {{ states('sensor.temperature') }} degree weather"
response_id: weather_joke
model: gpt-3.5-turbo
temperature: 0.7
max_tokens: 150
```
## Advanced Usage
### Custom API Endpoints
To use with Azure OpenAI or other compatible services, set the API Base URL during configuration:
- Azure OpenAI: `https://your-resource-name.openai.azure.com`
- Other Compatible APIs: Your API endpoint URL
### Rate Limiting
The integration includes built-in rate limiting:
- Requests are queued and processed according to the configured interval
- Default interval is 2 seconds
- Can be adjusted in the integration options
## Troubleshooting
Common issues and solutions:
1. **Response not appearing:**
- Check if the Text Helper was created (`input_text.text_ai_response_[your_id]`)
- Verify your API key permissions
2. **API errors:**
- Check your API key
- Verify the API endpoint URL
- Ensure you have sufficient API credits
3. **Rate limiting:**
- Adjust the request interval in the integration options
- Check for multiple automations making simultaneous requests
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch
3. Commit your changes
4. Push to the branch
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the LICENSE file for details.
| Option | Description | Default |
|--------|-------------|---------|
| `api_key` | Your OpenAI API key | Required |
| `model` | The AI model to use | gpt-3.5-turbo |
| `temperature` | Response randomness (0-1) | 0.7 |
| `max_tokens` | Maximum response length | 1000 |
| `api_endpoint` | Custom API endpoint | https://api.openai.com/v1 |
| `request_interval` | Minimum time between requests | 1.0 |
## Support
If you have any questions or need help, please:
- Open an [issue](https://github.com/smkrv/ha-text-ai/issues)
For bugs and feature requests, please create an issue on GitHub.
## License
This project is licensed under the MIT License.
```
@@ -1,203 +0,0 @@
blueprint:
name: Text AI Request
description: Send text prompts to AI service and store responses in a Text Helper
domain: automation
source_url: https://github.com/smkrv/ha-text-ai/blob/main/blueprints/automation/ha_text_ai_request.yaml
input:
# Trigger Configuration
trigger_type:
name: Trigger Type
description: Select the type of trigger for this automation
default: state
selector:
select:
options:
- state
- numeric_state
- time
- template
trigger_entity:
name: Trigger Entity
description: Entity that will trigger this automation when its state changes
selector:
entity: {}
# State Trigger Options
state_from:
name: From State
description: Previous state value to trigger the automation (optional, for state trigger)
default: ""
selector:
text:
state_to:
name: To State
description: New state value to trigger the automation (optional, for state trigger)
default: ""
selector:
text:
# Numeric State Options
numeric_above:
name: Above Value
description: Trigger when the numeric value exceeds this threshold
selector:
number:
mode: box
step: 0.1
numeric_below:
name: Below Value
description: Trigger when the numeric value falls below this threshold
selector:
number:
mode: box
step: 0.1
# Time Trigger Options
time_at:
name: Time
description: Specific time to trigger the automation (for time trigger)
selector:
time: {}
# Template Trigger Options
template_value:
name: Template
description: Template condition that evaluates to true/false (for template trigger)
selector:
template: {}
# AI Configuration
prompt_template:
name: Prompt Template
description: >
Template for the AI prompt. Supports Home Assistant template syntax.
Example: {{ states('sensor.temperature') }} degrees - what should I wear?
selector:
text:
multiline: true
response_id:
name: Response ID
description: Unique identifier for the Text Helper entity that will store the AI response
selector:
text:
model_name:
name: AI Model Name
description: The name of the AI model to use (e.g., gpt-4, gpt-3.5-turbo, claude-2)
selector:
text:
# Advanced AI Parameters
temperature:
name: Temperature
description: Controls randomness in the response (0 = deterministic, 2 = maximum creativity)
default: 0.7
selector:
number:
min: 0.0
max: 2.0
step: 0.1
mode: slider
max_tokens:
name: Maximum Tokens
description: Maximum length of the generated response in tokens
default: 150
selector:
number:
min: 1
max: 4096
step: 1
top_p:
name: Top P
description: Controls diversity via nucleus sampling (lower = more focused)
default: 1.0
selector:
number:
min: 0.0
max: 1.0
step: 0.1
mode: slider
frequency_penalty:
name: Frequency Penalty
description: Reduces repetition of token sequences (-2.0 to 2.0)
default: 0.0
selector:
number:
min: -2.0
max: 2.0
step: 0.1
mode: slider
presence_penalty:
name: Presence Penalty
description: Reduces repetition of topics (-2.0 to 2.0)
default: 0.0
selector:
number:
min: -2.0
max: 2.0
step: 0.1
mode: slider
mode: queued
max_exceeded: silent
trigger:
- choose:
- conditions:
- condition: template
value_template: "{{ trigger_type == 'state' }}"
sequence:
- platform: state
entity_id: !input trigger_entity
from: !input state_from
to: !input state_to
- conditions:
- condition: template
value_template: "{{ trigger_type == 'numeric_state' }}"
sequence:
- platform: numeric_state
entity_id: !input trigger_entity
above: !input numeric_above
below: !input numeric_below
- conditions:
- condition: template
value_template: "{{ trigger_type == 'time' }}"
sequence:
- platform: time
at: !input time_at
- conditions:
- condition: template
value_template: "{{ trigger_type == 'template' }}"
sequence:
- platform: template
value_template: !input template_value
variables:
prompt_template: !input prompt_template
response_id: !input response_id
model_name: !input model_name
temperature: !input temperature
max_tokens: !input max_tokens
top_p: !input top_p
frequency_penalty: !input frequency_penalty
presence_penalty: !input presence_penalty
action:
- service: ha_text_ai.text_ai_call
data:
prompt: "{{ prompt_template }}"
response_id: "{{ response_id }}"
model: "{{ model_name }}"
temperature: "{{ temperature }}"
max_tokens: "{{ max_tokens }}"
top_p: "{{ top_p }}"
frequency_penalty: "{{ frequency_penalty }}"
presence_penalty: "{{ presence_penalty }}"
@@ -1,199 +0,0 @@
blueprint:
name: Push Notification Sender
description: Send push notifications with customizable content and options
domain: automation
source_url: https://github.com/smkrv/ha-text-ai/blob/main/blueprints/automation/ha_push_notification.yaml
input:
# Trigger Configuration
trigger_type:
name: Trigger Type
description: Select the type of trigger for this automation
default: state
selector:
select:
options:
- state
- numeric_state
- time
- template
trigger_entity:
name: Trigger Entity
description: Entity that will trigger this automation when its state changes
selector:
entity: {}
# State Trigger Options
state_from:
name: From State
description: Previous state value to trigger the automation (optional, for state trigger)
default: ""
selector:
text:
state_to:
name: To State
description: New state value to trigger the automation (optional, for state trigger)
default: ""
selector:
text:
# Numeric State Options
numeric_above:
name: Above Value
description: Trigger when the numeric value exceeds this threshold
selector:
number:
mode: box
step: 0.1
numeric_below:
name: Below Value
description: Trigger when the numeric value falls below this threshold
selector:
number:
mode: box
step: 0.1
# Time Trigger Options
time_at:
name: Time
description: Specific time to trigger the automation (for time trigger)
selector:
time: {}
# Template Trigger Options
template_value:
name: Template
description: Template condition that evaluates to true/false (for template trigger)
selector:
template: {}
# Notification Configuration
notification_target:
name: Notification Target
description: The target device or group to receive the notification
selector:
text:
title_template:
name: Title Template
description: >
Template for the notification title. Supports Home Assistant template syntax.
Example: Temperature Alert: {{ states('sensor.temperature') }}°C
default: Home Assistant Notification
selector:
text:
multiline: true
message_template:
name: Message Template
description: >
Template for the notification message. Supports Home Assistant template syntax.
Example: Current temperature is {{ states('sensor.temperature') }}°C in {{ states('sensor.location') }}
selector:
text:
multiline: true
# Advanced Options
importance:
name: Importance Level
description: Set the importance/priority level of the notification
default: default
selector:
select:
options:
- low
- default
- high
notification_sound:
name: Notification Sound
description: Select a sound to play with the notification (optional)
default: default
selector:
text:
data_actions:
name: Actions
description: >
JSON array of actions (optional). Example:
[{"action": "URI", "title": "Open Map", "uri": "https://maps.google.com"}]
default: "[]"
selector:
text:
multiline: true
# Additional Options
group_notifications:
name: Group Notifications
description: Group similar notifications together
default: false
selector:
boolean:
notification_timeout:
name: Timeout
description: Time in seconds before the notification disappears (optional)
selector:
number:
min: 0
max: 86400
step: 1
mode: box
mode: queued
max_exceeded: silent
trigger:
- choose:
- conditions:
- condition: template
value_template: "{{ trigger_type == 'state' }}"
sequence:
- platform: state
entity_id: !input trigger_entity
from: !input state_from
to: !input state_to
- conditions:
- condition: template
value_template: "{{ trigger_type == 'numeric_state' }}"
sequence:
- platform: numeric_state
entity_id: !input trigger_entity
above: !input numeric_above
below: !input numeric_below
- conditions:
- condition: template
value_template: "{{ trigger_type == 'time' }}"
sequence:
- platform: time
at: !input time_at
- conditions:
- condition: template
value_template: "{{ trigger_type == 'template' }}"
sequence:
- platform: template
value_template: !input template_value
variables:
notification_target: !input notification_target
title_template: !input title_template
message_template: !input message_template
importance: !input importance
notification_sound: !input notification_sound
data_actions: !input data_actions
group_notifications: !input group_notifications
notification_timeout: !input notification_timeout
action:
- service: notify.mobile_app_{{ notification_target }}
data:
title: "{{ title_template }}"
message: "{{ message_template }}"
data:
importance: "{{ importance }}"
sound: "{{ notification_sound }}"
actions: "{{ data_actions }}"
group: "{{ group_notifications }}"
timeout: "{{ notification_timeout }}"
+144 -150
View File
@@ -1,156 +1,150 @@
"""The HA Text AI Integration."""
from __future__ import annotations
"""The HA text AI integration."""
import logging
from typing import Any
import asyncio
import logging
from typing import Any
import voluptuous as vol
from openai import AsyncOpenAI
import voluptuous as vol
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.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers import config_validation as cv
from homeassistant.components import input_text
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,
)
from .coordinator import HATextAICoordinator
from .const import (
DOMAIN,
CONF_API_BASE,
CONF_REQUEST_INTERVAL,
DEFAULT_API_BASE,
DEFAULT_REQUEST_INTERVAL,
TEXT_HELPER_PREFIX,
TEXT_HELPER_MAX_LENGTH,
)
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = []
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the HA Text AI component."""
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HA Text AI from a config entry."""
client = AsyncOpenAI(
api_key=entry.data[CONF_API_KEY],
base_url=entry.data.get(CONF_API_BASE, DEFAULT_API_BASE)
)
hass.data[DOMAIN][entry.entry_id] = {
"client": client,
CONF_REQUEST_INTERVAL: entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL),
"queue": [],
"processing": False,
}
async def create_text_helper(name: str) -> str:
"""Create a Text Helper if it doesn't exist."""
object_id = f"{TEXT_HELPER_PREFIX}{name}"
entity_id = f"input_text.{object_id}"
entity_registry = er.async_get(hass)
if entity_registry.async_get(entity_id) is None:
await hass.services.async_call(
"input_text",
"create",
{
"name": f"AI Response {name}",
"id": object_id,
"max": TEXT_HELPER_MAX_LENGTH,
"initial": "",
},
)
return entity_id
async def handle_text_ai_call(call: ServiceCall) -> None:
"""Handle the text AI service call."""
entry_id = list(hass.data[DOMAIN].keys())[0] # Используем первую настроенную интеграцию
response_id = call.data.get("response_id", "default")
entity_id = await create_text_helper(response_id)
request_data = {
"prompt": call.data["prompt"],
"model": call.data.get("model", "gpt-3.5-turbo"),
"temperature": call.data.get("temperature", 0.7),
"max_tokens": call.data.get("max_tokens", 150),
"top_p": call.data.get("top_p", 1.0),
"frequency_penalty": call.data.get("frequency_penalty", 0.0),
"presence_penalty": call.data.get("presence_penalty", 0.0),
"entity_id": entity_id,
}
hass.data[DOMAIN][entry_id]["queue"].append(request_data)
if not hass.data[DOMAIN][entry_id]["processing"]:
asyncio.create_task(process_queue(hass, entry_id))
async def process_queue(hass: HomeAssistant, entry_id: str) -> None:
"""Process the queue of requests."""
if hass.data[DOMAIN][entry_id]["processing"]:
return
hass.data[DOMAIN][entry_id]["processing"] = True
client = hass.data[DOMAIN][entry_id]["client"]
while hass.data[DOMAIN][entry_id]["queue"]:
request = hass.data[DOMAIN][entry_id]["queue"].pop(0)
try:
response = await client.chat.completions.create(
model=request["model"],
messages=[{"role": "user", "content": request["prompt"]}],
temperature=request["temperature"],
max_tokens=request["max_tokens"],
top_p=request["top_p"],
frequency_penalty=request["frequency_penalty"],
presence_penalty=request["presence_penalty"],
)
response_text = response.choices[0].message.content
await hass.services.async_call(
"input_text",
"set_value",
{
"entity_id": request["entity_id"],
"value": response_text
},
)
except Exception as e:
_LOGGER.error("Error processing request: %s", str(e))
await asyncio.sleep(hass.data[DOMAIN][entry_id][CONF_REQUEST_INTERVAL])
hass.data[DOMAIN][entry_id]["processing"] = False
hass.services.async_register(
DOMAIN,
"text_ai_call",
handle_text_ai_call,
schema=vol.Schema({
vol.Required("prompt"): cv.string,
vol.Optional("response_id", default="default"): cv.string,
vol.Optional("model", default="gpt-3.5-turbo"): cv.string,
vol.Optional("temperature", default=0.7): vol.Coerce(float),
vol.Optional("max_tokens", default=150): vol.Coerce(int),
vol.Optional("top_p", default=1.0): vol.Coerce(float),
vol.Optional("frequency_penalty", default=0.0): vol.Coerce(float),
vol.Optional("presence_penalty", default=0.0): vol.Coerce(float),
})
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
hass.data[DOMAIN].pop(entry.entry_id)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the HA text AI component from configuration.yaml."""
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HA text AI from a config entry."""
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),
)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def async_ask_question(call: ServiceCall) -> None:
"""Handle the ask_question service call."""
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
async def async_clear_history(call: ServiceCall) -> None:
"""Handle the clear_history service call."""
coordinator._responses.clear()
await coordinator.async_refresh()
async def async_get_history(call: ServiceCall) -> None:
"""Handle the get_history service call."""
limit = call.data.get("limit", 10)
history = list(coordinator._responses.items())[-limit:]
return {
"history": [
{"question": q, "response": r} for q, r in history
]
}
async def async_set_system_prompt(call: ServiceCall) -> None:
"""Handle the set_system_prompt service call."""
prompt = call.data["prompt"]
coordinator.system_prompt = prompt
# Register all 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.Coerce(float),
vol.Optional("max_tokens"): vol.Coerce(int),
})
)
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"): vol.Coerce(int),
})
)
hass.services.async_register(
DOMAIN,
SERVICE_SET_SYSTEM_PROMPT,
async_set_system_prompt,
schema=vol.Schema({
vol.Required("prompt"): cv.string,
})
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
# Unregister services
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
+77 -118
View File
@@ -1,130 +1,89 @@
"""Config flow for HA Text AI Integration."""
from __future__ import annotations
import logging
from typing import Any
#### `config_flow.py`
```python
"""Config flow for HA text AI integration."""
import voluptuous as vol
from homeassistant import config_entries
import homeassistant.helpers.config_validation as cv
from homeassistant.core import callback
import voluptuous as vol
from openai import AsyncOpenAI
from openai import OpenAIError, AuthenticationError, APIConnectionError
from .const import (
DOMAIN,
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,
)
from homeassistant import config_entries
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for HA text AI."""
from .const import (
DOMAIN,
CONF_API_KEY,
CONF_API_BASE,
CONF_REQUEST_INTERVAL,
DEFAULT_API_BASE,
DEFAULT_REQUEST_INTERVAL,
)
VERSION = 1
_LOGGER = logging.getLogger(__name__)
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
class TextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for HA Text AI Integration."""
if user_input is not None:
return self.async_create_entry(title="HA text AI", data=user_input)
VERSION = 1
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),
}),
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
if user_input is not None:
try:
await self._test_api_key(user_input[CONF_API_KEY], user_input.get(CONF_API_BASE))
return self.async_create_entry(
title="HA Text AI",
data=user_input,
)
except ApiKeyError:
errors["base"] = "invalid_api_key"
except ApiConnectionError:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for HA text AI."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Optional(CONF_API_BASE, default=DEFAULT_API_BASE): str,
vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): int,
}
),
errors=errors,
)
def __init__(self, config_entry):
"""Initialize options flow."""
self.config_entry = config_entry
@staticmethod
async def _test_api_key(api_key: str, api_base: str | None) -> None:
"""Test if the API key is valid."""
try:
client = AsyncOpenAI(
api_key=api_key,
base_url=api_base if api_base else DEFAULT_API_BASE
)
models = await client.models.list()
if not models.data:
raise ApiKeyError
except AuthenticationError as err:
raise ApiKeyError from err
except APIConnectionError as err:
raise ApiConnectionError from err
except OpenAIError as err:
if getattr(err, 'status_code', None) == 401:
raise ApiKeyError from err
raise ApiConnectionError from err
async def async_step_init(self, user_input=None):
"""Handle options flow."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> TextAIOptionsFlow:
"""Get the options flow for this handler."""
return TextAIOptionsFlow(config_entry)
class TextAIOptionsFlow(config_entries.OptionsFlow):
"""Handle options flow for HA Text AI Integration."""
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: dict[str, Any] | None = None
) -> FlowResult:
"""Manage options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_REQUEST_INTERVAL,
default=self.config_entry.options.get(
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
),
): int,
}
),
)
class ApiKeyError(HomeAssistantError):
"""Error to indicate there is an invalid API key."""
class ApiConnectionError(HomeAssistantError):
"""Error to indicate there is a connection error."""
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),
}),
)
+27 -10
View File
@@ -1,13 +1,30 @@
"""Constants for the HA Text AI Integration."""
DOMAIN = "ha_text_ai"
"""Constants for the HA text AI integration."""
from homeassistant.const import Platform
CONF_API_KEY = "api_key"
CONF_API_BASE = "api_base"
CONF_REQUEST_INTERVAL = "request_interval"
DOMAIN = "ha_text_ai"
PLATFORMS = [Platform.SENSOR]
DEFAULT_API_BASE = "https://api.openai.com/v1"
DEFAULT_REQUEST_INTERVAL = 5 # 5 seconds
# Configuration
CONF_MODEL = "model"
CONF_TEMPERATURE = "temperature"
CONF_MAX_TOKENS = "max_tokens"
CONF_API_ENDPOINT = "api_endpoint"
CONF_REQUEST_INTERVAL = "request_interval"
# Text Helper related
TEXT_HELPER_MAX_LENGTH = 65536
TEXT_HELPER_PREFIX = "text_ai_response_"
# 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
# Attributes
ATTR_QUESTION = "question"
ATTR_RESPONSE = "response"
ATTR_LAST_UPDATED = "last_updated"
# Services
SERVICE_ASK_QUESTION = "ask_question
SERVICE_CLEAR_HISTORY = "clear_history"
SERVICE_GET_HISTORY = "get_history"
SERVICE_SET_SYSTEM_PROMPT = "set_system_prompt"
@@ -0,0 +1,88 @@
"""Data coordinator for HA text AI."""
import asyncio
import logging
from datetime import timedelta
from typing import Any, Dict
import openai
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import (
DOMAIN,
DEFAULT_REQUEST_INTERVAL,
)
_LOGGER = logging.getLogger(__name__)
class HATextAICoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the API."""
def __init__(
self,
hass: HomeAssistant,
api_key: str,
endpoint: str,
model: str,
temperature: float,
max_tokens: int,
request_interval: float,
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=request_interval),
)
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._responses: Dict[str, Any] = {}
openai.api_key = self.api_key
if endpoint != "https://api.openai.com/v1":
openai.api_base = endpoint
async def _async_update_data(self) -> Dict[str, Any]:
"""Update data via OpenAI API."""
if self._question_queue.empty():
return self._responses
try:
question = await self._question_queue.get()
response = await self.hass.async_add_executor_job(
self._make_api_call, question
)
self._responses[question] = 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 _make_api_call(self, question: str) -> str:
"""Make API call to OpenAI."""
try:
completion = openai.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": question}],
temperature=self.temperature,
max_tokens=self.max_tokens,
)
return completion.choices[0].message.content
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)
await self.async_refresh()
+14 -16
View File
@@ -1,17 +1,15 @@
{
"domain": "ha_text_ai",
"name": "HA Text AI",
"version": "1.0.0",
"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": [],
"codeowners": ["@smkrv"],
"iot_class": "cloud_polling",
"min_ha_version": "2023.8.0",
"icon": "images/icon.svg"
{
"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": [],
"codeowners": ["@smkrv"],
"version": "1.0.1",
"iot_class": "cloud_polling"
}
+44
View File
@@ -0,0 +1,44 @@
"""Sensor platform for HA text AI."""
from typing import Any, Callable, Dict, Optional
from homeassistant.components.sensor import SensorEntity
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 .const import DOMAIN, ATTR_QUESTION, ATTR_RESPONSE, ATTR_LAST_UPDATED
from .coordinator import HATextAICoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the HA text AI sensor."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([HATextAISensor(coordinator, entry)], True)
class HATextAISensor(CoordinatorEntity, SensorEntity):
"""HA text AI Sensor."""
def __init__(
self,
coordinator: HATextAICoordinator,
config_entry: ConfigEntry,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._config_entry = config_entry
self._attr_unique_id = f"{config_entry.entry_id}"
self._attr_name = "HA text AI"
@property
def extra_state_attributes(self) -> Optional[Dict[str, Any]]:
"""Return entity specific state attributes."""
return {
ATTR_QUESTION: list(self.coordinator.data.keys())[-1] if self.coordinator.data else None,
ATTR_RESPONSE: list(self.coordinator.data.values())[-1] if self.coordinator.data else None,
ATTR_LAST_UPDATED: self.coordinator.last_update_success_time,
}
@@ -1,34 +1,38 @@
{
"config": {
"abort": {
"already_configured": "Service is already configured"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_api_key": "Invalid API key",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"api_key": "API Key",
"api_base": "API Base URL",
"request_interval": "Request interval (seconds)"
},
"description": "Enter your OpenAI API credentials",
"title": "OpenAI API"
}
}
},
"options": {
"step": {
"init": {
"data": {
"request_interval": "Request interval (seconds)"
},
"description": "Configure HA Text AI options",
"title": "HA Text AI Options"
}
}
}
{
"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)"
}
}
},
"error": {
"auth": "API key is invalid.",
"cannot_connect": "Failed to connect to API.",
"unknown": "Unexpected error occurred."
},
"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)"
}
}
}
}
}
@@ -1,34 +0,0 @@
{
"config": {
"abort": {
"already_configured": "Сервис уже настроен"
},
"error": {
"cannot_connect": "Ошибка подключения",
"invalid_api_key": "Неверный API ключ",
"unknown": "Неожиданная ошибка"
},
"step": {
"user": {
"data": {
"api_key": "API ключ",
"api_base": "Базовый URL API",
"request_interval": "Интервал запросов (секунды)"
},
"description": "Введите учетные данные OpenAI API",
"title": "OpenAI API"
}
}
},
"options": {
"step": {
"init": {
"data": {
"request_interval": "Интервал запросов (секунды)"
},
"description": "Настройка параметров HA Text AI",
"title": "Настройки HA Text AI"
}
}
}
}
+5 -4
View File
@@ -1,5 +1,6 @@
{
"name": "HA Text AI Integration",
"content_in_root": false,
"render_readme": true
{
"name": "HA text AI",
"render_readme": true,
"domains": ["sensor"],
"homeassistant": "2023.8.0"
}
-8
View File
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<rect x="8" y="14" width="8" height="5" rx="1"/>
<path d="M2 12h4"/>
<path d="M18 12h4"/>
</svg>

Before

Width:  |  Height:  |  Size: 451 B

+27
View File
@@ -0,0 +1,27 @@
ha_text_ai/
├── custom_components/
│ └── ha_text_ai/
│ ├── __init__.py
│ ├── manifest.json
│ ├── config_flow.py
│ ├── const.py
│ ├── coordinator.py
│ ├── sensor.py
│ ├── services.yaml
│ └── translations/
│ ├── en.json
│ └── ru.json
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ └── workflows/
│ ├── hassfest.yaml
│ └── validate.yaml
├── tests/
│ └── test_init.py
├── .gitignore
├── LICENSE
├── README.md
├── hacs.json
└── info.md
+23
View File
@@ -0,0 +1,23 @@
"""Tests for the HA text AI integration."""
from unittest.mock import patch
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from custom_components.ha_text_ai.const import DOMAIN
@pytest.mark.asyncio
async def test_setup(hass: HomeAssistant):
"""Test the setup."""
with patch('custom_components.ha_text_ai.coordinator.HATextAICoordinator'):
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