diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..2e00940 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @gritaro diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..0cc3ab5 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: +- package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + +- package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every weekday + interval: "daily" diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000..a06bf95 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,36 @@ +# These settings are synced to GitHub by https://probot.github.io/apps/settings/ + +repository: + name: ha-smartchain + description: "SmartChain — multi-provider LLM conversation agent for Home Assistant (GigaChat, YandexGPT, OpenAI) with tool calling and streaming" + homepage: https://github.com/gritaro/ha-smartchain + topics: homeassistant, hacs-integration, voice-assistant, langchain, gigachat, yandexgpt, openai, llm, smart-home, ai-agent + private: false + has_issues: true + has_projects: false + has_wiki: false + default_branch: main + allow_squash_merge: true + use_squash_pr_title_as_default: true + allow_merge_commit: false + allow_rebase_merge: true + delete_branch_on_merge: true + enable_vulnerability_alerts: true + +labels: +- name: "Feature Request" + color: "00ffbb" +- name: "Bug" + color: "e30000" +- name: "Wont Fix" + color: "ffffff" +- name: "Enhancement" + color: "48ff00" +- name: "Documentation" + color: "0077ff" +- name: "Help needed" + color: "fbca04" +- name: "dependencies" + color: "000000" +- name: "github_actions" + color: "000000" diff --git a/.github/workflows/cron.yaml b/.github/workflows/cron.yaml new file mode 100644 index 0000000..85030c2 --- /dev/null +++ b/.github/workflows/cron.yaml @@ -0,0 +1,21 @@ +name: Cron HACS actions + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v6" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" diff --git a/.github/workflows/hacs.yaml b/.github/workflows/hacs.yaml deleted file mode 100644 index ea6cf6f..0000000 --- a/.github/workflows/hacs.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: HACS Action - -on: - push: - pull_request: - schedule: - - cron: "0 0 * * *" - -jobs: - hacs: - name: HACS Action - runs-on: "ubuntu-latest" - steps: - - name: HACS Action - uses: "hacs/action@main" - with: - category: "integration" diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml deleted file mode 100644 index 5f7a071..0000000 --- a/.github/workflows/hassfest.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: Validate with hassfest - -on: - push: - pull_request: - schedule: - - cron: '0 0 * * *' - -jobs: - validate: - runs-on: "ubuntu-latest" - steps: - - uses: "actions/checkout@v3" - - uses: "home-assistant/actions/hassfest@master" diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml new file mode 100644 index 0000000..b3eb8ba --- /dev/null +++ b/.github/workflows/pull.yml @@ -0,0 +1,40 @@ +name: Pull actions + +on: + pull_request: + +env: + PYTHON_VERSION: "3.12" + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v6" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + lint: + runs-on: "ubuntu-latest" + name: Lint with ruff + steps: + - uses: "actions/checkout@v6" + - uses: "astral-sh/setup-uv@v5" + - run: uv run ruff check . + - run: uv run ruff format --check . + + test: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - uses: "actions/checkout@v6" + - uses: "astral-sh/setup-uv@v5" + - run: uv sync + - run: uv run pytest tests/ -v diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..044b124 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,44 @@ +name: Push actions + +on: + push: + branches: + - main + - rc-* + +env: + PYTHON_VERSION: "3.12" + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v6" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + lint: + runs-on: "ubuntu-latest" + name: Lint with ruff + steps: + - uses: "actions/checkout@v6" + - uses: "astral-sh/setup-uv@v5" + - run: uv run ruff check . + - run: uv run ruff format --check . + + test: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - uses: "actions/checkout@v6" + - uses: "astral-sh/setup-uv@v5" + - run: uv sync + - run: uv run pytest tests/ -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9c9721 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__ +.idea +/shelf/ +/workspace.xml +/httpRequests/ +/dataSources/ +/dataSources.local.xml +*.iws +*.iml +*.ipr +/.mcp.json +/.serena/ +.pytest_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..55460f3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.7 + hooks: + - id: ruff + - id: ruff-format diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..00c2e8c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +All notable changes to this project are documented in this file. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +project follows [Semantic Versioning](https://semver.org/). + +## [0.7.0] - 2026-03-10 + +### Changed +- **Project renamed: GigaChain -> SmartChain** — reflects multi-provider nature (not GigaChat-only) +- Domain: `gigachain` -> `smartchain` +- Entity classes: `GigaChainConversationEntity` -> `SmartChainConversationEntity` +- New GitHub repository: `ha-smartchain` +- HACS name: SmartChain +- Version bumped to 0.7.0 + +### Added +- **AI Task entity** — `SmartChainAITaskEntity` implements `ai_task.AITaskEntity` with `_async_generate_data()` for automation-driven text generation via `ai_task.generate_data` service +- Structured output support in AI Task (JSON parsing with `task.structure`) +- Tool calling support in AI Task (reuses conversation entity's LangChain integration) + +## [0.6.0] - 2026-03-10 + +### Added +- **Assist API for device control** — integration with HA LLM API (`async_provide_llm_data`) allows LLM to call Home Assistant services (turn on/off lights, locks, etc.) +- `llm_hass_api` option in Options Flow — select HA API for LLM (Assist, custom APIs) +- HA `llm.Tool` (voluptuous schema) -> LangChain tools conversion via `voluptuous_openapi` + `client.bind_tools()` +- Tool calling loop with `MAX_TOOL_ITERATIONS = 10` +- `tool_calls` in `AIMessageChunk` -> HA `ToolInput` in stream deltas +- `ToolResultContent` <-> LangChain `ToolMessage` conversion in `_chatlog_to_langchain()` + +### Changed +- `_async_handle_message` — uses `async_provide_llm_data` when LLM API configured, manual prompt otherwise +- Options Flow: `common_config_option_schema` takes `hass` to list available LLM APIs + +## [0.5.0] - 2026-03-10 + +### Added +- **Streaming responses** — `_attr_supports_streaming = True`, responses streamed via `ChatLog.async_add_delta_content_stream()` +- Async generator `_async_langchain_stream()` for `AIMessageChunk` -> HA delta dicts + +### Changed +- `client.invoke()` via `async_add_executor_job` replaced with `client.astream()` (async, no executor) + +## [0.4.0] - 2026-03-10 + +### Changed +- **ChatLog for history** — removed custom `OrderedDict`, uses native HA `ChatLog` +- **Migration to langchain-gigachat/langchain-openai** — proper package imports + +## [0.3.0] - 2026-03-10 + +### Added +- **Migration to ConversationEntity** — entity-based conversation agent with `_async_handle_message(user_input, chat_log)` + +## [0.2.0] - 2026-03-10 + +### Fixed +- Blocking LLM calls, deprecated LangChain API, memory leaks, model defaults + +### Removed +- Anyscale support completely removed + +## [0.1.8] - 2024-12-01 + +### Fixed +- Compatibility with Home Assistant 2024.12.1+ + +## [0.1.1] - 2024-03-01 + +### Added +- Initial release with GigaChat, YandexGPT support +- Config Flow and Options Flow +- Chat history and Jinja2 system prompts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8b5278c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# SmartChain — Project Rules + +## Project Overview +SmartChain is a Home Assistant custom integration providing a multi-provider LLM conversation agent via LangChain. + +- **Domain**: `smartchain` +- **HA Platform**: `Platform.CONVERSATION` (+ `Platform.AI_TASK` planned) +- **Supported LLM providers**: GigaChat, YandexGPT, OpenAI +- **Planned providers**: Ollama, DeepSeek, Anthropic +- **GitHub**: https://github.com/dzerik/ha-smartchain + +## Architecture + +### Core Files +- `custom_components/smartchain/__init__.py` — entry setup, client creation +- `custom_components/smartchain/conversation.py` — `SmartChainConversationEntity` (streaming, tool calling) +- `custom_components/smartchain/ai_task.py` — `SmartChainAITaskEntity` (data generation) +- `custom_components/smartchain/config_flow.py` — Config Flow + Options Flow +- `custom_components/smartchain/client_util.py` — LLM client factory (`get_client`, `validate_client`) +- `custom_components/smartchain/const.py` — all constants, prompts, model lists + +### Key Patterns +- **Streaming**: `client.astream()` -> `_async_langchain_stream()` -> `chat_log.async_add_delta_content_stream()` +- **Tool calling**: HA `llm.Tool` -> `_ha_tool_to_dict()` -> `client.bind_tools()` -> loop until no `unresponded_tool_results` +- **ChatLog conversion**: `_chatlog_to_langchain()` converts HA ChatLog content to LangChain message list +- **System prompt**: With Assist API — `async_provide_llm_data()`, without — manual Jinja2 template + `DEFAULT_DEVICES_PROMPT` + +### Tests +- `tests/test_config_flow.py` — 11 config flow tests +- `tests/test_init.py` — 19 conversation entity tests +- `tests/test_setup.py` — 4 setup/unload tests +- Run: `python3 -m pytest tests/ -v` + +## Development Rules + +### Naming +- Entity classes: `SmartChain*Entity` (e.g., `SmartChainConversationEntity`) +- Imports: `from custom_components.smartchain.X import Y` +- Domain constant: `DOMAIN = "smartchain"` + +### Testing +- Always run tests before committing: `python3 -m pytest tests/ -v` +- All tests must pass +- Mock LLM clients with `MagicMock` + `astream` side_effect +- Use `_make_chat_log()` helper for mock ChatLog with streaming support + +### Dependencies +- `langchain-gigachat>=0.3.0` — GigaChat provider +- `langchain-openai>=0.3.0` — OpenAI provider +- `langchain-community>=0.4.0` — YandexGPT and others +- `home-assistant-intents` — language support +- `yandexcloud==0.295.0` — Yandex Cloud SDK + +### Version Policy +- Manifest version in `custom_components/smartchain/manifest.json` +- Follow semver: PATCH for fixes, MINOR for features, MAJOR for breaking changes +- Current: 0.7.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8738494 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 gritaro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README-ru.md b/README-ru.md new file mode 100644 index 0000000..8244256 --- /dev/null +++ b/README-ru.md @@ -0,0 +1,73 @@ +[](https://github.com/gritaro/ha-smartchain/blob/main/README.md) +[](https://github.com/gritaro/ha-smartchain/blob/main/README-ru.md) + +
Мультипровайдерный LLM-ассистент для Home Assistant
+Multi-provider LLM conversation agent for Home Assistant
+
-
+[](https://hacs.xyz)
+[](https://github.com/gritaro/ha-smartchain/releases)
-
+#### OpenAI
+Get an API key at [platform.openai.com](https://platform.openai.com/account/api-keys)
-### Конфигурация
+## Usage
-* Темплейт промпта
-* Модель
+Create a Voice Assistant in HA settings and select your SmartChain entity as the conversation agent.
-## Использование в качестве диалоговой системы
-Создайте и настройте новый голосовой ассистент:
+## License
-
+MIT
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..44affd5
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,32 @@
+# SmartChain — TODO
+
+## Phase 1 — Competitive parity
+
+- [x] v0.6 Assist API — device control via tool calling
+- [x] v0.7 Rename GigaChain -> SmartChain + AI Task entity
+- [ ] v0.8.1 Ollama — local models (T-Pro, Qwen, Llama, Home-3B)
+- [ ] v0.8.2 DeepSeek — cheapest cloud provider
+- [ ] v0.8.3 Anthropic — Claude via LangChain
+- [ ] v0.9 Sub-entries — multiple agents with different models/prompts
+
+## Phase 2 — Differentiation
+
+- [ ] v1.0 Vision — camera image analysis (GigaChat 2.0, GPT-4o, Ollama)
+- [ ] v1.1 Image generation — Kandinsky (GigaChat) + YandexART
+- [ ] v1.2 MCP — connect external MCP servers as tools
+- [ ] v1.3 State history — LLM analyzes past events and trends
+
+## Phase 3 — Leadership
+
+- [ ] v1.4 Multi-agent — Dispatcher + specialized agents (LangGraph)
+- [ ] v1.5 Telegram bot — home control via Telegram
+- [ ] v1.6 STT/TTS — Yandex SpeechKit, full voice pipeline
+- [ ] v1.7 Skill system — loadable skills from YAML
+- [ ] v1.8 Prompt caching — token savings on repeated requests
+
+## Technical debt
+
+- [ ] Tests: Options Flow (config_flow)
+- [ ] Test: integration with real ChatLog (not mock)
+- [ ] E2E test: tool calling loop
+- [ ] HACS: verify compatibility and publish
diff --git a/custom_components/gigachain/__init__.py b/custom_components/gigachain/__init__.py
deleted file mode 100644
index 6b40da6..0000000
--- a/custom_components/gigachain/__init__.py
+++ /dev/null
@@ -1,104 +0,0 @@
-"""The GigaChain integration."""
-from __future__ import annotations
-from abc import abstractmethod
-import re
-from homeassistant.components import conversation
-from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import MATCH_ALL
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import (
- config_validation as cv,
- intent,
- template,
-)
-from homeassistant.components.conversation import AgentManager, agent
-from typing import Literal
-from langchain.chat_models import GigaChat
-from langchain.prompts.chat import (
- AIMessagePromptTemplate,
- ChatPromptTemplate,
- HumanMessagePromptTemplate,
- SystemMessagePromptTemplate,
-)
-from langchain.schema import AIMessage, HumanMessage, SystemMessage
-from homeassistant.util import ulid
-from .const import (
- DOMAIN,
- CONF_AUTH_DATA,
- CONF_CHAT_MODEL,
- DEFAULT_CHAT_MODEL,
- CONF_PROMPT,
- DEFAULT_PROMPT
- )
-import logging
-
-LOGGER = logging.getLogger(__name__)
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Initialize GigaChain."""
- client = GigaChat(credentials=entry.data[CONF_AUTH_DATA], verify_ssl_certs=False)
- models = client.get_models()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client
- conversation.async_set_agent(hass, entry, GigaChatAI(hass, entry))
- return True
-
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Unload GigaChain."""
- hass.data[DOMAIN].pop(entry.entry_id)
- conversation.async_unset_agent(hass, entry)
- return True
-
-class GigaChatAI(conversation.AbstractConversationAgent):
- def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Initialize the agent."""
- self.hass = hass
- self.entry = entry
- self.history: dict[str, list[dict]] = {}
-
- @property
- def supported_languages(self) -> list[str] | Literal["*"]:
- """Return a list of supported languages."""
- return MATCH_ALL
-
- async def async_process(
- self, user_input: agent.ConversationInput
- ) -> agent.ConversationResult:
- """Process a sentence."""
- raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT)
- model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL)
- if user_input.conversation_id in self.history:
- conversation_id = user_input.conversation_id
- messages = self.history[conversation_id]
- else:
- conversation_id = ulid.ulid_now()
- prompt = self._async_generate_prompt(raw_prompt)
- messages = [
- SystemMessage(
- content=prompt
- )
- ]
-
- messages.append(HumanMessage(content=user_input.text))
- client = self.hass.data[DOMAIN][self.entry.entry_id]
- client.model = model
-
- res = client(messages)
- messages.append(res)
- self.history[conversation_id] = messages
-
- response = intent.IntentResponse(language=user_input.language)
- response.async_set_speech(res.content)
- return agent.ConversationResult(
- conversation_id=conversation_id,
- response=response
- )
-
- def _async_generate_prompt(self, raw_prompt: str) -> str:
- """Generate a prompt for the user."""
- return template.Template(raw_prompt, self.hass).async_render(
- {
- "ha_name": self.hass.config.location_name,
- },
- parse_result=False,
- )
diff --git a/custom_components/gigachain/__pycache__/__init__.cpython-312.pyc b/custom_components/gigachain/__pycache__/__init__.cpython-312.pyc
deleted file mode 100644
index f53ea50..0000000
Binary files a/custom_components/gigachain/__pycache__/__init__.cpython-312.pyc and /dev/null differ
diff --git a/custom_components/gigachain/__pycache__/config_flow.cpython-312.pyc b/custom_components/gigachain/__pycache__/config_flow.cpython-312.pyc
deleted file mode 100644
index dddf035..0000000
Binary files a/custom_components/gigachain/__pycache__/config_flow.cpython-312.pyc and /dev/null differ
diff --git a/custom_components/gigachain/__pycache__/const.cpython-312.pyc b/custom_components/gigachain/__pycache__/const.cpython-312.pyc
deleted file mode 100644
index 340af6b..0000000
Binary files a/custom_components/gigachain/__pycache__/const.cpython-312.pyc and /dev/null differ
diff --git a/custom_components/gigachain/config_flow.py b/custom_components/gigachain/config_flow.py
deleted file mode 100644
index d1571ed..0000000
--- a/custom_components/gigachain/config_flow.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""Config flow for GigaChain integration."""
-from __future__ import annotations
-
-from typing import Any
-
-import voluptuous as vol
-
-from homeassistant import config_entries
-from homeassistant.data_entry_flow import FlowResult
-import types
-from types import MappingProxyType
-from homeassistant.helpers.selector import (
- NumberSelector,
- NumberSelectorConfig,
- TemplateSelector,
-)
-from .const import (
- CONF_AUTH_DATA,
- CONF_CHAT_MODEL,
- CONF_PROMPT,
- DEFAULT_CHAT_MODEL,
- DEFAULT_PROMPT,
- DOMAIN
-)
-
-STEP_USER_DATA_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_AUTH_DATA): str
- }
-)
-
-DEFAULT_OPTIONS = types.MappingProxyType(
- {
- CONF_PROMPT: DEFAULT_PROMPT,
- CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL,
- }
-)
-
-class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
- """Handle a config flow for GigaChain."""
-
- VERSION = 1
-
- async def async_step_user(
- self, user_input: dict[str, Any] | None = None
- ) -> FlowResult:
- """Handle the initial step."""
- if user_input is None:
- return self.async_show_form(
- step_id="user", data_schema=STEP_USER_DATA_SCHEMA
- )
-
- unique_id = "GigaChat"
- await self.async_set_unique_id(unique_id)
- self._abort_if_unique_id_configured()
- return self.async_create_entry(title=unique_id, data=user_input)
-
- @staticmethod
- def async_get_options_flow(
- config_entry: config_entries.ConfigEntry,
- ) -> config_entries.OptionsFlow:
- """Create the options flow."""
- return OptionsFlow(config_entry)
-
-class OptionsFlow(config_entries.OptionsFlow):
- """GigaChain config flow options handler."""
-
- 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 the options."""
- if user_input is not None:
- return self.async_create_entry(title="GigaChat", data=user_input)
- schema = gigachat_config_option_schema(self.config_entry.options)
- return self.async_show_form(
- step_id="init",
- data_schema=vol.Schema(schema),
- )
-
-def gigachat_config_option_schema(options: MappingProxyType[str, Any]) -> dict:
- """Return a schema for GigaChain completion options."""
- if not options:
- options = DEFAULT_OPTIONS
- return {
- vol.Optional(
- CONF_PROMPT,
- description={"suggested_value": options[CONF_PROMPT]},
- default=DEFAULT_PROMPT,
- ): TemplateSelector(),
- vol.Optional(
- CONF_CHAT_MODEL,
- description={
- # New key in HA 2023.4
- "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL)
- },
- default=DEFAULT_CHAT_MODEL,
- ): str,
- }
diff --git a/custom_components/gigachain/const.py b/custom_components/gigachain/const.py
deleted file mode 100644
index 36b9bc6..0000000
--- a/custom_components/gigachain/const.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Constants for the GigaChain integration."""
-
-DOMAIN = "gigachain"
-CONF_AUTH_DATA = "auth_data"
-CONF_PROMPT = "prompt"
-DEFAULT_PROMPT = """Ты HAL 9000, компьютер из цикла произведений «Космическая одиссея» Артура Кларка, обладающий способностью к самообучению.
-Мы находимся в умном доме под управлением системы Home Assistant.
-В доме есть следующие помещения и устройства:
-{%- for area in areas() %}
- {%- set area_info = namespace(printed=false) %}
- {%- for device in area_devices(area) -%}
- {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %}
- {%- if not area_info.printed %}
-
-{{ area_name(area) }}:
- {%- set area_info.printed = true %}
- {%- endif %}
-- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %}
- {%- endif %}
- {%- endfor %}
-{%- endfor %}
-Когда отвечаешь, обращайся к собеседнику по имени Дэйв.
-"""
-
-CONF_CHAT_MODEL = "model"
-#GigaChat-Plus,GigaChat-Pro,GigaChat:latest
-DEFAULT_CHAT_MODEL = "GigaChat"
diff --git a/custom_components/gigachain/manifest.json b/custom_components/gigachain/manifest.json
deleted file mode 100644
index 28cb0f1..0000000
--- a/custom_components/gigachain/manifest.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "domain": "gigachain",
- "name": "GigaChain",
- "codeowners": [
- "@gritaro"
- ],
- "config_flow": true,
- "dependencies": [
- "conversation"
- ],
- "documentation": "https://github.com/gritaro/gigachain",
- "homekit": {},
- "integration_type": "service",
- "iot_class": "cloud_polling",
- "issue_tracker": "https://github.com/gritaro/gigachain/issues",
- "requirements": [
- "gigachain",
- "yandexcloud"
- ],
- "version": "0.1.0"
-}
diff --git a/custom_components/gigachain/strings.json b/custom_components/gigachain/strings.json
deleted file mode 100644
index cd4bc14..0000000
--- a/custom_components/gigachain/strings.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "title": "GigaChain configuration",
- "data": {
- "auth_data": "Authorization data"
- }
- }
- },
- "abort": {
- "already_configured": "Already configured"
- }
- },
- "options": {
- "step": {
- "init": {
- "data": {
- "prompt": "Prompt Template",
- "model": "Completion Model"
- }
- }
- }
- }
-}
diff --git a/custom_components/gigachain/translations/en.json b/custom_components/gigachain/translations/en.json
deleted file mode 100644
index cd4bc14..0000000
--- a/custom_components/gigachain/translations/en.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "title": "GigaChain configuration",
- "data": {
- "auth_data": "Authorization data"
- }
- }
- },
- "abort": {
- "already_configured": "Already configured"
- }
- },
- "options": {
- "step": {
- "init": {
- "data": {
- "prompt": "Prompt Template",
- "model": "Completion Model"
- }
- }
- }
- }
-}
diff --git a/custom_components/gigachain/translations/ru.json b/custom_components/gigachain/translations/ru.json
deleted file mode 100644
index 4ff4e76..0000000
--- a/custom_components/gigachain/translations/ru.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "title": "GigaChain конфигурация",
- "data": {
- "auth_data": "Авторизационные данные"
- }
- }
- },
- "abort": {
- "already_configured": "Нельзя настроить более одной интеграции"
- }
- },
- "options": {
- "step": {
- "init": {
- "data": {
- "prompt": "Промпт темплейт",
- "model": "Модель"
- }
- }
- }
- }
-}
diff --git a/custom_components/smartchain/__init__.py b/custom_components/smartchain/__init__.py
new file mode 100644
index 0000000..455fa8c
--- /dev/null
+++ b/custom_components/smartchain/__init__.py
@@ -0,0 +1,60 @@
+"""The SmartChain integration."""
+
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .client_util import get_client
+from .const import (
+ CONF_CHAT_MODEL,
+ CONF_CHAT_MODEL_USER,
+ CONF_ENGINE,
+ CONF_MAX_TOKENS,
+ CONF_TEMPERATURE,
+ DEFAULT_TEMPERATURE,
+ ID_GIGACHAT,
+)
+
+LOGGER = logging.getLogger(__name__)
+
+PLATFORMS = [Platform.CONVERSATION, Platform.AI_TASK]
+
+
+async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Update listener."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Initialize SmartChain."""
+ engine = entry.data.get(CONF_ENGINE) or ID_GIGACHAT
+ model = entry.options.get(CONF_CHAT_MODEL_USER)
+ if not model or not model.strip():
+ model = entry.options.get(CONF_CHAT_MODEL)
+ temperature = entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
+ max_tokens = entry.options.get(CONF_MAX_TOKENS)
+
+ entry.async_on_unload(entry.add_update_listener(update_listener))
+
+ common_args = {
+ "verbose": False,
+ "model": model,
+ }
+ if temperature is not None:
+ common_args["temperature"] = temperature
+ if max_tokens is not None:
+ common_args["max_tokens"] = max_tokens
+
+ client = await get_client(hass, engine, entry, common_args)
+
+ entry.runtime_data = client
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload SmartChain."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/custom_components/smartchain/ai_task.py b/custom_components/smartchain/ai_task.py
new file mode 100644
index 0000000..41ca2e5
--- /dev/null
+++ b/custom_components/smartchain/ai_task.py
@@ -0,0 +1,99 @@
+"""AI Task entity for SmartChain integration."""
+
+import logging
+from typing import Any
+
+from homeassistant.components import ai_task, conversation
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import MAX_TOOL_ITERATIONS
+from .conversation import (
+ _async_langchain_stream,
+ _chatlog_to_langchain,
+ _ha_tool_to_dict,
+)
+
+LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up AI Task entity."""
+ async_add_entities([SmartChainAITaskEntity(config_entry)])
+
+
+class SmartChainAITaskEntity(ai_task.AITaskEntity):
+ """SmartChain AI Task entity for data generation."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+
+ def __init__(self, entry: ConfigEntry) -> None:
+ """Initialize the entity."""
+ self.entry = entry
+ self._attr_unique_id = f"{entry.entry_id}_ai_task"
+ self._attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA
+
+ async def _async_generate_data(
+ self,
+ task: ai_task.GenDataTask,
+ chat_log: conversation.ChatLog,
+ ) -> ai_task.GenDataTaskResult:
+ """Handle a generate data task."""
+ client = self.entry.runtime_data
+ tools: list[dict[str, Any]] = (
+ [_ha_tool_to_dict(tool) for tool in chat_log.llm_api.tools]
+ if chat_log.llm_api
+ else []
+ )
+ bound_client = client.bind_tools(tools) if tools else client
+
+ for _iteration in range(MAX_TOOL_ITERATIONS):
+ messages = _chatlog_to_langchain(chat_log)
+
+ try:
+ async for _content in chat_log.async_add_delta_content_stream(
+ self.entity_id,
+ _async_langchain_stream(bound_client, messages),
+ ):
+ pass
+ except Exception as err:
+ LOGGER.exception("AI Task error: %s", type(err))
+ raise HomeAssistantError(f"AI Task error: {err}") from err
+
+ if not chat_log.unresponded_tool_results:
+ break
+
+ if not isinstance(chat_log.content[-1], conversation.AssistantContent):
+ raise HomeAssistantError(
+ "Last content in chat log is not an AssistantContent"
+ )
+
+ text = chat_log.content[-1].content or ""
+
+ if task.structure:
+ from homeassistant.util.json import json_loads
+ from json import JSONDecodeError
+
+ try:
+ data = json_loads(text)
+ except JSONDecodeError as err:
+ LOGGER.error("Failed to parse structured response: %s", err)
+ raise HomeAssistantError(
+ "Failed to parse structured AI Task response"
+ ) from err
+
+ return ai_task.GenDataTaskResult(
+ conversation_id=chat_log.conversation_id,
+ data=data,
+ )
+
+ return ai_task.GenDataTaskResult(
+ conversation_id=chat_log.conversation_id,
+ data=text,
+ )
diff --git a/custom_components/smartchain/client_util.py b/custom_components/smartchain/client_util.py
new file mode 100644
index 0000000..a1a0184
--- /dev/null
+++ b/custom_components/smartchain/client_util.py
@@ -0,0 +1,85 @@
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from langchain_core.messages import SystemMessage
+from langchain_community.chat_models import ChatYandexGPT
+from langchain_gigachat import GigaChat
+from langchain_openai import ChatOpenAI
+
+from .const import (
+ CONF_API_KEY,
+ CONF_ENGINE,
+ CONF_FOLDER_ID,
+ CONF_PROFANITY,
+ CONF_SKIP_VALIDATION,
+ CONF_VERIFY_SSL,
+ DEFAULT_PROFANITY,
+ DEFAULT_VERIFY_SSL,
+ ID_GIGACHAT,
+ ID_YANDEX_GPT,
+ ID_OPENAI,
+ DEFAULT_MODEL,
+)
+
+LOGGER = logging.getLogger(__name__)
+
+
+async def validate_client(
+ hass: HomeAssistant,
+ user_input: dict,
+) -> None:
+ """Validate LLM client connection."""
+ if user_input.get(CONF_SKIP_VALIDATION):
+ return
+ engine = user_input.get(CONF_ENGINE) or ID_GIGACHAT
+ if engine == ID_GIGACHAT:
+ client = GigaChat(
+ max_tokens=10,
+ verbose=False,
+ credentials=user_input[CONF_API_KEY],
+ verify_ssl_certs=False,
+ )
+ elif engine == ID_YANDEX_GPT:
+ client = ChatYandexGPT(
+ max_tokens=10,
+ max_retries=2,
+ api_key=user_input[CONF_API_KEY],
+ folder_id=user_input[CONF_FOLDER_ID],
+ )
+ else:
+ client = ChatOpenAI(
+ max_tokens=10,
+ model=DEFAULT_MODEL[ID_OPENAI],
+ openai_api_key=user_input[CONF_API_KEY],
+ )
+ await hass.async_add_executor_job(client.invoke, [SystemMessage(content="{}")])
+
+
+async def get_client(
+ hass: HomeAssistant,
+ engine: str,
+ entry: ConfigEntry,
+ common_args: dict,
+):
+ """Create LLM client based on engine type."""
+ if engine == ID_GIGACHAT:
+ common_args["credentials"] = entry.data[CONF_API_KEY]
+ common_args["verify_ssl_certs"] = entry.options.get(
+ CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL
+ )
+ common_args["profanity_check"] = entry.options.get(
+ CONF_PROFANITY, DEFAULT_PROFANITY
+ )
+ client = GigaChat(**common_args)
+ elif engine == ID_YANDEX_GPT:
+ common_args["api_key"] = entry.data[CONF_API_KEY]
+ common_args["folder_id"] = entry.data[CONF_FOLDER_ID]
+ common_args["max_retries"] = 2
+ client = ChatYandexGPT(**common_args)
+ else:
+ if common_args["model"] is None:
+ common_args["model"] = DEFAULT_MODEL[ID_OPENAI]
+ common_args["openai_api_key"] = entry.data[CONF_API_KEY]
+ client = ChatOpenAI(**common_args)
+ return client
diff --git a/custom_components/smartchain/config_flow.py b/custom_components/smartchain/config_flow.py
new file mode 100644
index 0000000..51f111c
--- /dev/null
+++ b/custom_components/smartchain/config_flow.py
@@ -0,0 +1,310 @@
+"""Config flow for SmartChain integration."""
+
+from __future__ import annotations
+
+import logging
+from types import MappingProxyType
+from typing import Any
+
+import voluptuous as vol
+from gigachat.exceptions import ResponseError
+from homeassistant import config_entries
+from homeassistant.config_entries import ConfigFlowResult
+from homeassistant.core import callback
+from homeassistant.helpers import selector
+from homeassistant.helpers.selector import (
+ NumberSelector,
+ NumberSelectorConfig,
+ SelectSelectorMode,
+ TemplateSelector,
+)
+from httpx import ConnectError
+
+from homeassistant.helpers import llm
+
+from .client_util import validate_client
+from .const import (
+ CONF_API_KEY,
+ CONF_CHAT_MODEL,
+ CONF_CHAT_MODEL_USER,
+ CONF_ENGINE,
+ CONF_ENGINE_OPTIONS,
+ CONF_FOLDER_ID,
+ CONF_LLM_HASS_API,
+ CONF_MAX_TOKENS,
+ CONF_PROFANITY,
+ CONF_PROMPT,
+ CONF_SKIP_VALIDATION,
+ CONF_TEMPERATURE,
+ CONF_VERIFY_SSL,
+ DEFAULT_CHAT_MODEL,
+ DEFAULT_VERIFY_SSL,
+ ENGINE_MODELS,
+ DEFAULT_PROFANITY,
+ DEFAULT_PROMPT,
+ DEFAULT_SKIP_VALIDATION,
+ DEFAULT_TEMPERATURE,
+ DOMAIN,
+ ID_GIGACHAT,
+ ID_OPENAI,
+ ID_YANDEX_GPT,
+ UNIQUE_ID,
+ CONF_PROCESS_BUILTIN_SENTENCES,
+ DEFAULT_PROCESS_BUILTIN_SENTENCES,
+ CONF_CHAT_HISTORY,
+ DEFAULT_CHAT_HISTORY,
+ UNIQUE_ID_GIGACHAT,
+)
+
+LOGGER = logging.getLogger(__name__)
+
+STEP_USER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_ENGINE): selector.SelectSelector(
+ selector.SelectSelectorConfig(options=CONF_ENGINE_OPTIONS),
+ ),
+ }
+)
+STEP_API_KEY_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): str,
+ vol.Optional(CONF_SKIP_VALIDATION, default=DEFAULT_SKIP_VALIDATION): bool,
+ }
+)
+STEP_YANDEXGPT_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): str,
+ vol.Required(CONF_FOLDER_ID): str,
+ vol.Optional(CONF_SKIP_VALIDATION, default=DEFAULT_SKIP_VALIDATION): bool,
+ }
+)
+
+ENGINE_SCHEMA = {
+ ID_GIGACHAT: STEP_API_KEY_SCHEMA,
+ ID_YANDEX_GPT: STEP_YANDEXGPT_SCHEMA,
+ ID_OPENAI: STEP_API_KEY_SCHEMA,
+}
+
+DEFAULT_OPTIONS = MappingProxyType(
+ {
+ CONF_PROMPT: DEFAULT_PROMPT,
+ CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL,
+ CONF_CHAT_MODEL_USER: DEFAULT_CHAT_MODEL,
+ CONF_PROCESS_BUILTIN_SENTENCES: DEFAULT_PROCESS_BUILTIN_SENTENCES,
+ }
+)
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for SmartChain."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ if user_input is None:
+ return self.async_show_form(step_id="user", data_schema=STEP_USER_SCHEMA)
+
+ engine = user_input[CONF_ENGINE]
+ unique_id = UNIQUE_ID[engine]
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
+ return self.async_show_form(step_id=engine, data_schema=ENGINE_SCHEMA[engine])
+
+ async def async_step_gigachat(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ return await self._common_model_async_step(ID_GIGACHAT, user_input)
+
+ async def async_step_yandexgpt(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ return await self._common_model_async_step(ID_YANDEX_GPT, user_input)
+
+ async def async_step_openai(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ return await self._common_model_async_step(ID_OPENAI, user_input)
+
+ async def _common_model_async_step(
+ self, engine: str, user_input: dict[str, Any] | None
+ ) -> ConfigFlowResult:
+ if user_input is None:
+ return self.async_show_form(
+ step_id=engine, data_schema=ENGINE_SCHEMA[engine]
+ )
+
+ errors: dict[str, str] = {}
+ user_input[CONF_ENGINE] = engine
+ unique_id = UNIQUE_ID[engine]
+ try:
+ await validate_client(self.hass, user_input)
+ except ConnectError:
+ errors["base"] = "cannot_connect"
+ except ResponseError:
+ errors["base"] = "invalid_response"
+ except Exception as inst:
+ LOGGER.exception("Unexpected exception %s", type(inst))
+ errors["base"] = "unknown"
+ else:
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title=unique_id, data=user_input)
+
+ return self.async_show_form(
+ step_id=engine, data_schema=ENGINE_SCHEMA[engine], errors=errors
+ )
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: config_entries.ConfigEntry,
+ ) -> config_entries.OptionsFlow:
+ """Create the options flow."""
+ return OptionsFlow(config_entry)
+
+
+class OptionsFlow(config_entries.OptionsFlow):
+ """SmartChain config flow options handler."""
+
+ 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
+ ) -> ConfigFlowResult:
+ """Manage the options."""
+ unique_id = self.config_entry.unique_id
+ schema = common_config_option_schema(
+ self.hass, unique_id, self.config_entry.options
+ )
+ if user_input is not None:
+ model = user_input.get(CONF_CHAT_MODEL_USER)
+ if not model or not model.strip():
+ model = user_input.get(CONF_CHAT_MODEL)
+ if not model or not model.strip():
+ return self.async_show_form(
+ step_id="init",
+ data_schema=schema,
+ errors={"base": "model_required"},
+ )
+
+ # Remove empty LLM API selection
+ if not user_input.get(CONF_LLM_HASS_API):
+ user_input.pop(CONF_LLM_HASS_API, None)
+
+ return self.async_create_entry(title=unique_id, data=user_input)
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=schema,
+ )
+
+
+def common_config_option_schema(
+ hass, unique_id: str, options: MappingProxyType[str, Any]
+) -> vol.Schema:
+ """Return a schema for SmartChain completion options."""
+ if not options:
+ options = DEFAULT_OPTIONS
+
+ hass_apis: list[selector.SelectOptionDict] = [
+ selector.SelectOptionDict(value=api.id, label=api.name)
+ for api in llm.async_get_apis(hass)
+ ]
+
+ schema = vol.Schema(
+ {
+ vol.Optional(
+ CONF_CHAT_MODEL,
+ description={
+ "suggested_value": options.get(CONF_CHAT_MODEL),
+ },
+ default="none",
+ ): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ mode=SelectSelectorMode("dropdown"),
+ options=ENGINE_MODELS[unique_id],
+ ),
+ ),
+ vol.Optional(
+ CONF_CHAT_MODEL_USER,
+ description={"suggested_value": options.get(CONF_CHAT_MODEL_USER)},
+ ): str,
+ vol.Optional(
+ CONF_LLM_HASS_API,
+ ): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=hass_apis,
+ multiple=True,
+ mode=SelectSelectorMode("dropdown"),
+ ),
+ ),
+ vol.Optional(
+ CONF_PROMPT,
+ description={
+ "suggested_value": options.get(CONF_PROMPT, DEFAULT_PROMPT)
+ },
+ default=DEFAULT_PROMPT,
+ ): TemplateSelector(),
+ vol.Optional(
+ CONF_TEMPERATURE,
+ description={
+ "suggested_value": options.get(
+ CONF_TEMPERATURE, DEFAULT_TEMPERATURE
+ )
+ },
+ default=DEFAULT_TEMPERATURE,
+ ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
+ vol.Optional(
+ CONF_MAX_TOKENS,
+ description={"suggested_value": options.get(CONF_MAX_TOKENS)},
+ ): int,
+ vol.Optional(
+ CONF_PROCESS_BUILTIN_SENTENCES,
+ description={
+ "suggested_value": options.get(
+ CONF_PROCESS_BUILTIN_SENTENCES,
+ DEFAULT_PROCESS_BUILTIN_SENTENCES,
+ )
+ },
+ default=DEFAULT_PROCESS_BUILTIN_SENTENCES,
+ ): bool,
+ vol.Optional(
+ CONF_CHAT_HISTORY,
+ description={
+ "suggested_value": options.get(
+ CONF_CHAT_HISTORY, DEFAULT_CHAT_HISTORY
+ )
+ },
+ default=DEFAULT_CHAT_HISTORY,
+ ): bool,
+ }
+ )
+ if unique_id == UNIQUE_ID_GIGACHAT:
+ schema = schema.extend(
+ {
+ vol.Optional(
+ CONF_PROFANITY,
+ description={
+ "suggested_value": options.get(
+ CONF_PROFANITY, DEFAULT_PROFANITY
+ )
+ },
+ default=DEFAULT_PROFANITY,
+ ): bool,
+ vol.Optional(
+ CONF_VERIFY_SSL,
+ description={
+ "suggested_value": options.get(
+ CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL
+ )
+ },
+ default=DEFAULT_VERIFY_SSL,
+ ): bool,
+ }
+ )
+ return schema
diff --git a/custom_components/smartchain/const.py b/custom_components/smartchain/const.py
new file mode 100644
index 0000000..3ab1be7
--- /dev/null
+++ b/custom_components/smartchain/const.py
@@ -0,0 +1,108 @@
+"""Constants for the SmartChain integration."""
+
+from homeassistant.helpers import selector
+
+DOMAIN = "smartchain"
+CONF_ENGINE = "engine"
+CONF_CHAT_MODEL = "model"
+CONF_CHAT_MODEL_USER = "model_user"
+DEFAULT_CHAT_MODEL = ""
+CONF_TEMPERATURE = "temperature"
+DEFAULT_TEMPERATURE = 0.1
+CONF_PROFANITY = "profanity"
+DEFAULT_PROFANITY = False
+CONF_VERIFY_SSL = "verify_ssl"
+DEFAULT_VERIFY_SSL = False
+CONF_MAX_TOKENS = "max_tokens"
+CONF_SKIP_VALIDATION = "skip_validation"
+DEFAULT_SKIP_VALIDATION = False
+CONF_PROCESS_BUILTIN_SENTENCES = "process_builtin_sentences"
+DEFAULT_PROCESS_BUILTIN_SENTENCES = True
+CONF_CHAT_HISTORY = "chat_history"
+DEFAULT_CHAT_HISTORY = True
+CONF_PROMPT = "prompt"
+DEFAULT_PROMPT = """You are a smart home voice assistant {{ ha_name }} powered by Home Assistant.
+Answer truthfully and to the point. Answer in plain text, briefly and clearly.
+Answer in the user's language."""
+
+DEFAULT_DEVICES_PROMPT = """
+The following rooms, devices and sensors are available in the home:
+{%- for area in areas() %}
+ {%- set area_info = namespace(printed=false) %}
+ {%- for device in area_devices(area) -%}
+ {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %}
+ {%- if not area_info.printed %}
+
+{{ area_name(area) }}:
+ {%- set area_info.printed = true %}
+ {%- endif %}
+- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %}
+
+ {%- for entity_id in device_entities(device) %}
+ {%- set entity_domain = entity_id.split('.')[0] %}
+ {%- set dc = state_attr(entity_id, 'device_class') %}
+ {%- set friendly = state_attr(entity_id, 'friendly_name') %}
+ {%- set entity_state = states(entity_id) %}
+ {%- if entity_state and entity_state != 'unavailable' %}
+ - {{ entity_id }} ({{ entity_domain }}{% if dc %}, {{ dc }}{% endif %}): {{ entity_state }}{% if state_attr(entity_id, 'unit_of_measurement') %} {{ state_attr(entity_id, 'unit_of_measurement') }}{% endif %}
+
+ {%- endif %}
+ {%- endfor %}
+ {%- endif %}
+ {%- endfor %}
+{%- endfor %}"""
+
+ID_GIGACHAT = "gigachat"
+ID_YANDEX_GPT = "yandexgpt"
+ID_OPENAI = "openai"
+UNIQUE_ID_GIGACHAT = "GigaChat"
+UNIQUE_ID_YANDEX_GPT = "YandexGPT"
+UNIQUE_ID_OPENAI = "OpenAI"
+
+UNIQUE_ID = {
+ ID_GIGACHAT: UNIQUE_ID_GIGACHAT,
+ ID_YANDEX_GPT: UNIQUE_ID_YANDEX_GPT,
+ ID_OPENAI: UNIQUE_ID_OPENAI,
+}
+
+CONF_ENGINE_OPTIONS = [
+ selector.SelectOptionDict(value=ID_GIGACHAT, label=UNIQUE_ID_GIGACHAT),
+ selector.SelectOptionDict(value=ID_YANDEX_GPT, label=UNIQUE_ID_YANDEX_GPT),
+ selector.SelectOptionDict(value=ID_OPENAI, label=UNIQUE_ID_OPENAI),
+]
+MODELS_GIGACHAT = [
+ "",
+ "GigaChat",
+ "GigaChat:latest",
+ "GigaChat-Plus",
+ "GigaChat-Pro",
+ "GigaChat-Max",
+]
+DEFAULT_MODELS_YANDEX_GPT = ["", "YandexGPT", "YandexGPT Lite", "Summary"]
+MODELS_OPENAI = [
+ "",
+ "gpt-4.1",
+ "gpt-4.1-mini",
+ "gpt-4.1-nano",
+ "gpt-4o",
+ "gpt-4o-mini",
+ "o3",
+ "o3-mini",
+ "o4-mini",
+]
+ENGINE_MODELS = {
+ UNIQUE_ID_GIGACHAT: MODELS_GIGACHAT,
+ UNIQUE_ID_YANDEX_GPT: DEFAULT_MODELS_YANDEX_GPT,
+ UNIQUE_ID_OPENAI: MODELS_OPENAI,
+}
+DEFAULT_MODEL = {
+ ID_GIGACHAT: None,
+ ID_OPENAI: "gpt-4.1-mini",
+ ID_YANDEX_GPT: None,
+}
+
+CONF_LLM_HASS_API = "llm_hass_api"
+CONF_API_KEY = "api_key"
+CONF_FOLDER_ID = "folder_id"
+
+MAX_TOOL_ITERATIONS = 10
diff --git a/custom_components/smartchain/conversation.py b/custom_components/smartchain/conversation.py
new file mode 100644
index 0000000..36e302e
--- /dev/null
+++ b/custom_components/smartchain/conversation.py
@@ -0,0 +1,237 @@
+"""Conversation entity for SmartChain integration."""
+
+import json
+import logging
+from collections.abc import AsyncIterable
+from typing import Any, Literal
+
+import voluptuous_openapi
+from home_assistant_intents import get_languages
+from homeassistant.components import conversation
+from homeassistant.components.conversation import (
+ ChatLog,
+ ConversationEntity,
+ ConversationInput,
+ ConversationResult,
+)
+from homeassistant.components.conversation.chat_log import (
+ AssistantContent,
+ SystemContent,
+ ToolResultContent,
+ UserContent,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers import intent, llm, template
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from langchain_core.messages import (
+ AIMessage,
+ BaseMessage,
+ HumanMessage,
+ SystemMessage,
+ ToolMessage,
+)
+
+from .const import (
+ CONF_CHAT_HISTORY,
+ CONF_LLM_HASS_API,
+ CONF_PROCESS_BUILTIN_SENTENCES,
+ CONF_PROMPT,
+ DEFAULT_CHAT_HISTORY,
+ DEFAULT_DEVICES_PROMPT,
+ DEFAULT_PROCESS_BUILTIN_SENTENCES,
+ DEFAULT_PROMPT,
+ DOMAIN,
+ MAX_TOOL_ITERATIONS,
+)
+
+LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up conversation entity."""
+ async_add_entities([SmartChainConversationEntity(config_entry)])
+
+
+def _ha_tool_to_dict(tool: llm.Tool) -> dict[str, Any]:
+ """Convert HA llm.Tool to dict for LangChain bind_tools."""
+ parameters = voluptuous_openapi.convert(tool.parameters)
+ return {
+ "name": tool.name,
+ "description": tool.description or "",
+ "parameters": parameters,
+ }
+
+
+class SmartChainConversationEntity(ConversationEntity):
+ """SmartChain conversation entity using ConversationEntity API."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+ _attr_supports_streaming = True
+
+ def __init__(self, entry: ConfigEntry) -> None:
+ """Initialize the entity."""
+ self.entry = entry
+ self._attr_unique_id = entry.entry_id
+
+ @property
+ def supported_languages(self) -> list[str] | Literal["*"]:
+ """Return a list of supported languages."""
+ return get_languages()
+
+ async def _async_handle_message(
+ self,
+ user_input: ConversationInput,
+ chat_log: ChatLog,
+ ) -> ConversationResult:
+ """Handle a conversation message via ChatLog API."""
+ options = self.entry.options
+ llm_hass_api = options.get(CONF_LLM_HASS_API)
+ user_prompt = options.get(CONF_PROMPT, DEFAULT_PROMPT)
+
+ if llm_hass_api:
+ try:
+ await chat_log.async_provide_llm_data(
+ user_input.as_llm_context(DOMAIN),
+ llm_hass_api,
+ user_prompt,
+ user_input.extra_system_prompt,
+ )
+ except conversation.ConverseError as err:
+ return err.as_conversation_result()
+ else:
+ raw_prompt = user_prompt + DEFAULT_DEVICES_PROMPT
+ prompt = template.Template(raw_prompt, self.hass).async_render(
+ {"ha_name": self.hass.config.location_name},
+ parse_result=False,
+ )
+ chat_log.content[0] = SystemContent(content=prompt)
+
+ use_builtin = options.get(
+ CONF_PROCESS_BUILTIN_SENTENCES, DEFAULT_PROCESS_BUILTIN_SENTENCES
+ )
+ if use_builtin and not llm_hass_api:
+ from homeassistant.components.conversation import agent_manager
+
+ default_agent = agent_manager.async_get_agent(self.hass, None)
+ default_response = await default_agent.async_process(user_input)
+
+ if default_response.response.intent:
+ speech = default_response.response.speech.get("plain", {}).get(
+ "speech", ""
+ )
+ chat_log.async_add_assistant_content_without_tools(
+ AssistantContent(
+ agent_id=user_input.agent_id,
+ content=speech,
+ )
+ )
+ return default_response
+
+ client = self.entry.runtime_data
+ tools = (
+ [_ha_tool_to_dict(tool) for tool in chat_log.llm_api.tools]
+ if chat_log.llm_api
+ else []
+ )
+ bound_client = client.bind_tools(tools) if tools else client
+
+ for _iteration in range(MAX_TOOL_ITERATIONS):
+ chat_history_enabled = options.get(CONF_CHAT_HISTORY, DEFAULT_CHAT_HISTORY)
+ if chat_history_enabled:
+ messages = _chatlog_to_langchain(chat_log)
+ else:
+ messages = [
+ SystemMessage(content=chat_log.content[0].content),
+ HumanMessage(content=user_input.text),
+ ]
+
+ try:
+ async for _content in chat_log.async_add_delta_content_stream(
+ user_input.agent_id,
+ _async_langchain_stream(bound_client, messages),
+ ):
+ pass
+ except Exception as err:
+ LOGGER.exception("Unexpected exception %s", type(err))
+ response = intent.IntentResponse(language=user_input.language)
+ response.async_set_error(
+ intent.IntentResponseErrorCode.UNKNOWN,
+ f"Houston we have a problem: {err}",
+ )
+ return ConversationResult(
+ conversation_id=chat_log.conversation_id, response=response
+ )
+
+ if not chat_log.unresponded_tool_results:
+ break
+
+ return conversation.async_get_result_from_chat_log(user_input, chat_log)
+
+
+def _chatlog_to_langchain(chat_log: ChatLog) -> list[BaseMessage]:
+ """Convert ChatLog content to LangChain message list."""
+ messages: list[BaseMessage] = []
+ for content in chat_log.content:
+ if isinstance(content, SystemContent):
+ messages.append(SystemMessage(content=content.content))
+ elif isinstance(content, UserContent):
+ messages.append(HumanMessage(content=content.content))
+ elif isinstance(content, AssistantContent):
+ if content.tool_calls:
+ tool_calls = [
+ {
+ "id": tc.id,
+ "name": tc.tool_name,
+ "args": tc.tool_args,
+ }
+ for tc in content.tool_calls
+ ]
+ messages.append(
+ AIMessage(
+ content=content.content or "",
+ tool_calls=tool_calls,
+ )
+ )
+ elif content.content:
+ messages.append(AIMessage(content=content.content))
+ elif isinstance(content, ToolResultContent):
+ messages.append(
+ ToolMessage(
+ content=json.dumps(content.tool_result),
+ tool_call_id=content.tool_call_id,
+ name=content.tool_name,
+ )
+ )
+ return messages
+
+
+async def _async_langchain_stream(
+ client: Any, messages: list[BaseMessage]
+) -> AsyncIterable[dict[str, Any]]:
+ """Convert LangChain astream chunks to HA delta dicts."""
+ first = True
+ async for chunk in client.astream(messages):
+ delta: dict[str, Any] = {}
+ if first:
+ delta["role"] = "assistant"
+ first = False
+ if chunk.content:
+ delta["content"] = chunk.content
+
+ if chunk.tool_calls:
+ delta["tool_calls"] = [
+ llm.ToolInput(
+ tool_name=tc["name"],
+ tool_args=tc["args"],
+ id=tc["id"],
+ )
+ for tc in chunk.tool_calls
+ ]
+
+ if delta:
+ yield delta
diff --git a/custom_components/smartchain/manifest.json b/custom_components/smartchain/manifest.json
new file mode 100644
index 0000000..ce4f43c
--- /dev/null
+++ b/custom_components/smartchain/manifest.json
@@ -0,0 +1,20 @@
+{
+ "domain": "smartchain",
+ "name": "SmartChain",
+ "codeowners": ["@dzerik"],
+ "config_flow": true,
+ "dependencies": ["ai_task", "conversation"],
+ "documentation": "https://github.com/dzerik/ha-smartchain",
+ "homekit": {},
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "issue_tracker": "https://github.com/dzerik/ha-smartchain/issues",
+ "requirements": [
+ "home-assistant-intents",
+ "langchain-gigachat>=0.3.0",
+ "langchain-openai>=0.3.0",
+ "langchain-community>=0.4.0",
+ "yandexcloud==0.295.0"
+ ],
+ "version": "0.7.0"
+}
diff --git a/custom_components/smartchain/strings.json b/custom_components/smartchain/strings.json
new file mode 100644
index 0000000..1fe8687
--- /dev/null
+++ b/custom_components/smartchain/strings.json
@@ -0,0 +1,64 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "SmartChain configuration - select engine",
+ "data": {
+ "engine": "LLM Engine"
+ }
+ },
+ "gigachat": {
+ "title": "GigaChat configuration",
+ "data": {
+ "api_key": "Auth data",
+ "skip_validation": "Skip validation"
+ }
+ },
+ "yandexgpt": {
+ "title": "YandexGPT configuration",
+ "data": {
+ "api_key": "API Key",
+ "folder_id": "Folder ID",
+ "skip_validation": "Skip validation"
+ }
+ },
+ "openai": {
+ "title": "OpenAI configuration",
+ "data": {
+ "api_key": "API Key",
+ "skip_validation": "Skip validation"
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "Already configured"
+ },
+ "error": {
+ "cannot_connect": "Can not connect",
+ "invalid_response": "Invalid response",
+ "unknown": "Unknown error"
+ }
+ },
+ "options": {
+ "error": {
+ "model_required": "Either Model or Custom Model required"
+ },
+ "step": {
+ "init": {
+ "title": "Model configuration",
+ "data": {
+ "llm_hass_api": "Assist API (Control HA devices)",
+ "prompt": "Prompt Template",
+ "model": "Completion Model",
+ "model_user": "Custom Model Name (leave empty to use from list above)",
+ "temperature": "Temperature",
+ "max_tokens": "Max Tokens",
+ "profanity": "Profanity",
+ "verify_ssl": "Verify SSL Certificates",
+ "process_builtin_sentences": "Process HA Builtin Sentences",
+ "chat_history": "Chat History"
+ }
+ }
+ }
+ }
+}
diff --git a/custom_components/smartchain/translations/en.json b/custom_components/smartchain/translations/en.json
new file mode 100644
index 0000000..1fe8687
--- /dev/null
+++ b/custom_components/smartchain/translations/en.json
@@ -0,0 +1,64 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "SmartChain configuration - select engine",
+ "data": {
+ "engine": "LLM Engine"
+ }
+ },
+ "gigachat": {
+ "title": "GigaChat configuration",
+ "data": {
+ "api_key": "Auth data",
+ "skip_validation": "Skip validation"
+ }
+ },
+ "yandexgpt": {
+ "title": "YandexGPT configuration",
+ "data": {
+ "api_key": "API Key",
+ "folder_id": "Folder ID",
+ "skip_validation": "Skip validation"
+ }
+ },
+ "openai": {
+ "title": "OpenAI configuration",
+ "data": {
+ "api_key": "API Key",
+ "skip_validation": "Skip validation"
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "Already configured"
+ },
+ "error": {
+ "cannot_connect": "Can not connect",
+ "invalid_response": "Invalid response",
+ "unknown": "Unknown error"
+ }
+ },
+ "options": {
+ "error": {
+ "model_required": "Either Model or Custom Model required"
+ },
+ "step": {
+ "init": {
+ "title": "Model configuration",
+ "data": {
+ "llm_hass_api": "Assist API (Control HA devices)",
+ "prompt": "Prompt Template",
+ "model": "Completion Model",
+ "model_user": "Custom Model Name (leave empty to use from list above)",
+ "temperature": "Temperature",
+ "max_tokens": "Max Tokens",
+ "profanity": "Profanity",
+ "verify_ssl": "Verify SSL Certificates",
+ "process_builtin_sentences": "Process HA Builtin Sentences",
+ "chat_history": "Chat History"
+ }
+ }
+ }
+ }
+}
diff --git a/custom_components/smartchain/translations/ru.json b/custom_components/smartchain/translations/ru.json
new file mode 100644
index 0000000..5ceae8d
--- /dev/null
+++ b/custom_components/smartchain/translations/ru.json
@@ -0,0 +1,64 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "SmartChain - выбор LLM провайдера",
+ "data": {
+ "engine": "LLM провайдер"
+ }
+ },
+ "gigachat": {
+ "title": "GigaChat",
+ "data": {
+ "api_key": "Авторизационные данные",
+ "skip_validation": "Пропустить проверку"
+ }
+ },
+ "yandexgpt": {
+ "title": "YandexGPT",
+ "data": {
+ "api_key": "API ключ",
+ "folder_id": "Идентификатор каталога (Folder ID)",
+ "skip_validation": "Пропустить проверку"
+ }
+ },
+ "openai": {
+ "title": "OpenAI",
+ "data": {
+ "api_key": "API ключ",
+ "skip_validation": "Пропустить проверку"
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "Этот провайдер уже настроен"
+ },
+ "error": {
+ "cannot_connect": "Не удаётся подключиться",
+ "invalid_response": "Некорректный ответ",
+ "unknown": "Неизвестная ошибка"
+ }
+ },
+ "options": {
+ "error": {
+ "model_required": "Выберите модель из списка либо задайте свою"
+ },
+ "step": {
+ "init": {
+ "title": "Настройки модели",
+ "data": {
+ "llm_hass_api": "Assist API (управление устройствами HA)",
+ "prompt": "Системный промпт",
+ "model": "Модель",
+ "model_user": "Своё имя модели (оставьте пустым для использования из списка)",
+ "temperature": "Температура",
+ "max_tokens": "Максимум токенов",
+ "profanity": "Цензура",
+ "verify_ssl": "Проверка SSL сертификатов",
+ "process_builtin_sentences": "Встроенный командный процессор HA",
+ "chat_history": "История сообщений"
+ }
+ }
+ }
+ }
+}
diff --git a/docs/COMPETITIVE_ANALYSIS.md b/docs/COMPETITIVE_ANALYSIS.md
new file mode 100644
index 0000000..5726c6a
--- /dev/null
+++ b/docs/COMPETITIVE_ANALYSIS.md
@@ -0,0 +1,382 @@
+# SmartChain — Конкурентный анализ и точки роста
+
+Дата: 2026-03-10 | Версия: 0.5.0
+
+## Оглавление
+
+1. [Конкуренты — интеграции HA для LLM](#1-конкуренты--интеграции-ha-для-llm)
+2. [Что есть у конкурентов, чего нет у нас](#2-что-есть-у-конкурентов-чего-нет-у-нас)
+3. [Российские и китайские модели](#3-российские-и-китайские-модели--кого-можно-подключить)
+4. [Легковесные локальные модели](#4-легковесные-локальные-модели)
+5. [Точки роста и пути развития](#5-точки-роста-и-пути-развития)
+6. [Стратегическое позиционирование](#6-стратегическое-позиционирование)
+7. [Источники](#7-источники)
+
+---
+
+## 1. Конкуренты — интеграции HA для LLM
+
+### Официальные интеграции (встроены в HA core)
+
+| Интеграция | Провайдер | Device Control | AI Task | Streaming | Vision | MCP |
+|---|---|---|---|---|---|---|
+| OpenAI Conversation | OpenAI | Assist API | да | да | да | да |
+| Anthropic | Claude | Assist API | да | да | нет | да |
+| Google Gemini | Gemini | Assist API | да | да | да | да |
+| Ollama | Локальные | Assist API | да | да | нет | да |
+| OpenRouter | 400+ моделей | Assist API | да | да | нет | да |
+
+Все официальные интеграции поддерживают:
+- Assist API для управления устройствами
+- AI Task entity для генерации данных в автоматизациях
+- Sub-entries (несколько агентов с разными моделями через одну интеграцию)
+- MCP (Model Context Protocol) для расширения возможностей внешними инструментами
+- Streaming ответов
+- Conversational follow-ups (LLM может задавать уточняющие вопросы)
+- Per-device LLM assignment (разный LLM для разных устройств)
+
+### Custom-интеграции (HACS)
+
+#### Extended OpenAI Conversation (~1.5k stars)
+- GitHub: https://github.com/jekalmin/extended_openai_conversation
+- **Function calling** — вызов сервисов HA через OpenAI API
+- **Создание автоматизаций** через естественный язык
+- **Чтение истории состояний** — LLM знает, что было раньше
+- **REST API** — запросы к внешним API
+- **Веб-скрейпинг** — получение данных с веб-страниц
+- **Skill-система** — загружаемые навыки из директории
+- **Multi-agent** — Dispatcher Agent маршрутизирует запросы между специализированными агентами
+- **Attach Username** — персонализация по имени пользователя
+- Поддержка native functions: `execute_service`, `add_automation`, `get_history`
+
+#### YandexGPT (black-roland, 38 stars)
+- GitHub: https://github.com/black-roland/homeassistant-yandexgpt
+- **Управление устройствами** — включение/выключение света, регулировка температуры, запуск скриптов
+- **YandexART** — генерация изображений
+- **Telegram-бот** — использование как backend для Telegram
+- **Yandex SpeechKit** — companion-интеграция для STT/TTS
+- Последний релиз: v1.5.5 (декабрь 2025)
+- Лицензия: MPL-2.0
+
+#### Cloud.ru Foundation Models (black-roland, новый)
+- GitHub: https://github.com/black-roland/homeassistant-cloud-ru-ai
+- **Открытые модели**: GPT-OSS-120b, Qwen3, Llama, DeepSeek R1 Distill, GLM-4.5, T-Pro
+- **Управление устройствами** — полный контроль через чат
+- Основан на официальной интеграции OpenAI HA
+- Лицензия: Apache 2.0
+
+#### Home-LLM (acon96, ~3k stars)
+- GitHub: https://github.com/acon96/home-llm
+- **Полностью локальный** — никаких облачных сервисов
+- **Fine-tuned модели** — Home-3B-v3 (97% точность function calling), Home-1B-v3
+- **Tool calling** — переписан для поддержки agentic tool use loop
+- **AI Task entity** — генерация данных для автоматизаций
+- **Multi-language** — EN, DE, FR, ES, PL
+- **CPU-friendly** — работает на Raspberry Pi
+- Backends: Ollama, llama.cpp, OpenAI-compatible API
+
+#### LLM Vision (~1.5k stars)
+- GitHub: https://github.com/valentinfrlch/ha-llmvision
+- **Мультимодальный** — анализ изображений, видео, live камер, Frigate events
+- **Timeline** — хранит историю событий камер
+- **Распознавание** — люди, номерные знаки, объекты
+- **Голосовые запросы** — "Была ли активность во дворе вчера?"
+- Провайдеры: OpenAI, Anthropic, Gemini, Ollama, OpenRouter, и др.
+
+#### SmartChain (наш, 15 stars)
+- GigaChat + YandexGPT + OpenAI через LangChain
+- Streaming ответов
+- ChatLog для истории
+- Builtin sentence processor
+- Системный промпт с Jinja2
+
+---
+
+## 2. Что есть у конкурентов, чего нет у нас
+
+### Критические отставания
+
+| Фича | У кого есть | Описание |
+|---|---|---|
+| Управление устройствами (Assist API) | Все official, Extended OpenAI, YandexGPT, Cloud.ru | LLM может включать свет, менять температуру, запускать сценарии |
+| AI Task entity | Все official, Home-LLM | Генерация данных для автоматизаций через `ai_task.generate_data` |
+| MCP (Model Context Protocol) | Все official | Расширение возможностей агента внешними инструментами |
+| Function calling / вызов сервисов | Extended OpenAI, все official | LLM вызывает произвольные сервисы HA |
+| Vision / мультимодальность | OpenAI, Gemini, LLM Vision | Анализ камер и изображений |
+| Генерация изображений | YandexGPT (YandexART) | Создание изображений по описанию |
+| Создание автоматизаций через AI | Extended OpenAI | Генерация автоматизаций HA из естественного языка |
+| Доступ к истории состояний | Extended OpenAI | LLM анализирует тренды и прошлые события |
+
+### Менее критичные
+
+| Фича | У кого есть |
+|---|---|
+| Multi-agent (Dispatcher + специализированные) | Extended OpenAI |
+| Skill-система (загружаемые навыки) | Extended OpenAI |
+| Telegram-бот | YandexGPT |
+| STT/TTS от того же провайдера | YandexGPT + SpeechKit |
+| Prompt caching | llama.cpp custom-conversation |
+| Sub-entries (несколько агентов) | Все official (HA 2025.8+) |
+| Conversational follow-ups | Все official |
+| Per-device LLM assignment | Все official |
+
+---
+
+## 3. Российские и китайские модели — кого можно подключить
+
+### Российские модели с API
+
+#### GigaChat 2.0 (Сбер) — уже подключён
+- Линейка: Lite / Pro / MAX
+- Контекст: до 128K токенов
+- Мультимодальность: да (GigaChat 2.0)
+- Генерация изображений: да (Kandinsky)
+- Цена: ~650 руб/1M токенов (MAX), 1M бесплатных токенов/месяц для разработчиков
+- LangChain: `langchain-gigachat` (подключён)
+- Function calling: поддерживается
+
+#### YandexGPT 4 / Alice AI LLM (Яндекс) — уже подключён
+- Линейка: Lite / Pro
+- Контекст: до 32K токенов (до 60 страниц)
+- Цена: ~1220 руб/1M токенов (Pro)
+- LangChain: `langchain_community.chat_models.ChatYandexGPT` (подключён)
+- Доп. сервисы: YandexART (генерация изображений), SpeechKit (STT/TTS)
+- Примечание: в октябре 2025 переименован в Alice AI LLM
+
+#### T-Pro 2.0 (Т-Банк / Т-Технологии) — НЕ подключён
+- Параметры: 32B
+- Лицензия: Apache 2.0 (полностью открытая)
+- Гибридный reasoning (быстрые ответы + многошаговое рассуждение)
+- На 30% экономичнее Qwen3 и DeepSeek R1-Distil на русском
+- Лидер бенчмарков MERA, ruMMLU, ruArena Hard для русского
+- Основана на Qwen3 32B с улучшенным кириллическим токенизатором
+- Доступ: HuggingFace, self-hosted через Ollama/vLLM
+- **Рекомендация: подключить через Ollama backend**
+
+#### T-Lite (Т-Банк) — НЕ подключён
+- Параметры: 7B
+- Лицензия: Apache 2.0
+- Компактная модель для устройств со средней производительностью
+- Доступ: HuggingFace, Ollama
+- **Рекомендация: подключить через Ollama как легковесную опцию**
+
+#### Cotype Pro 2.5 (MTS AI / MWS AI) — НЕ подключён
+- Лидер среди российских LLM в бенчмарке MERA
+- Агентные навыки: в 10 раз эффективнее Cotype Pro 2
+- Превосходит Qwen3-32B на 22% по точности на русском
+- Доступ: enterprise API (MWS Cloud)
+- **Рекомендация: рассмотреть при наличии API**
+
+#### Cotype Nano (MTS AI) — НЕ подключён
+- Открытая модель, работает на мобильных устройствах и ноутбуках
+- Лучшие результаты в своём классе на Ru Arena Hard
+- **Рекомендация: подключить через Ollama**
+
+### Китайские модели (доступны из России)
+
+#### DeepSeek V3 / R1 — НЕ подключён
+- V3: general-purpose, tool calling, structured output
+- R1: reasoning, chain-of-thought
+- LangChain: `langchain-deepseek` (`ChatDeepSeek`)
+- Цена: самая низкая на рынке (~$0.14/1M input tokens)
+- Доступ из РФ: API (возможны проблемы с блокировкой IP Роскомнадзором), self-hosted через Ollama
+- **Рекомендация: подключить как самый дешёвый cloud-провайдер**
+
+#### Qwen 3 (Alibaba) — НЕ подключён
+- Модели от 0.6B до 235B параметров
+- Самая скачиваемая серия моделей на HuggingFace (2025-2026)
+- Reasoning mode, tool calling
+- LangChain: `ChatOllama` или Cloud.ru
+- **Рекомендация: подключить через Ollama backend**
+
+#### GLM-4.5 (Zhipu AI) — НЕ подключён
+- Доступ: Cloud.ru Foundation Models
+- **Рекомендация: подключить через Cloud.ru или Ollama**
+
+#### Baichuan, Yi, MiniCPM — НЕ подключены
+- Open-source, self-hosted через Ollama
+- **Рекомендация: автоматически доступны при поддержке Ollama**
+
+---
+
+## 4. Легковесные локальные модели
+
+Для пользователей без мощного GPU или с Raspberry Pi:
+
+| Модель | Параметры | RAM | Язык | Особенности |
+|---|---|---|---|---|
+| Home-3B-v3 | 3B | ~2GB | EN/DE/FR/ES | 97% точность function calling HA |
+| Home-1B-v3 | 1B | ~1GB | EN | Raspberry Pi compatible |
+| T-Lite | 7B | ~4GB | RU | лучшая для русского в классе 7B |
+| T-Pro 2.0 | 32B | ~18GB | RU | лидер русскоязычных бенчмарков |
+| Qwen3 | 0.6B-4B | 0.5-3GB | RU/EN/ZH | reasoning, tool calling |
+| Phi-4-mini | 3.8B | ~2GB | EN | Microsoft, компактная |
+| Gemma 3 | 1B-4B | 1-3GB | EN | Google, vision support |
+| DeepSeek R1 Distill | 1.5B-14B | 1-8GB | EN/ZH | reasoning |
+| Cotype Nano | small | ~2GB | RU | MTS AI, открытая |
+| MiniCPM | 1.2B-8B | 1-4GB | ZH/EN | OpenBMB, компактная |
+
+Все локальные модели доступны через **Ollama** — единый backend для self-hosted LLM.
+
+---
+
+## 5. Точки роста и пути развития
+
+### Приоритет: Высокий (конкурентный паритет)
+
+#### 5.1 Управление устройствами через Assist API
+- **Что:** Использовать `chat_log.async_provide_llm_data()` для доступа к HA tools
+- **Зачем:** Главная фича, которую имеют ВСЕ конкуренты. Без неё SmartChain — только чат-бот, не smart home agent
+- **Как:** HA LLM API предоставляет intents для управления exposed entities. Нужно передать tools в LLM и обработать tool calls
+- **Сложность:** Средняя. GigaChat и DeepSeek поддерживают function calling. Паттерн задокументирован в HA developer docs
+
+#### 5.2 AI Task entity
+- **Что:** Добавить `AITaskEntity` с методом `_async_generate_data()`
+- **Зачем:** Позволит использовать SmartChain в автоматизациях, скриптах, шаблонах через `ai_task.generate_data`
+- **Как:** Новый entity наряду с ConversationEntity, может разделять общую логику обработки chat_log
+- **Сложность:** Низкая-средняя
+
+#### 5.3 Новые провайдеры LLM
+- **DeepSeek** (`pip install langchain-deepseek`) — `ChatDeepSeek`, самый дешёвый, tool calling
+- **Ollama** (`pip install langchain-ollama`) — `ChatOllama`, все локальные модели (T-Pro, Qwen, Llama, DeepSeek)
+- **Anthropic** (`pip install langchain-anthropic`) — `ChatAnthropic`, extended thinking
+- **Зачем:** Расширение аудитории, поддержка локальных моделей, снижение стоимости
+- **Сложность:** Низкая — архитектура через LangChain уже позволяет добавлять провайдеров в `client_util.py`
+
+### Приоритет: Средний (дифференциация)
+
+#### 5.4 Function calling / вызов сервисов HA
+- **Что:** LLM вызывает произвольные сервисы HA (свет, климат, сценарии)
+- **Зачем:** Extended OpenAI Conversation — самая популярная custom-интеграция именно из-за этой фичи
+- **Как:** Определить tool specs для `execute_service`, `get_history`, передавать в LLM, обрабатывать tool calls в цикле
+- **Сложность:** Средняя-высокая
+
+#### 5.5 Vision / мультимодальность
+- **Что:** Анализ изображений с камер через LLM
+- **Зачем:** GigaChat 2.0 поддерживает мультимодальность. Можно анализировать камеры без LLM Vision
+- **Как:** Передавать изображения как attachments в LLM запрос
+- **Сложность:** Средняя
+
+#### 5.6 Генерация изображений
+- **Что:** GigaChat (Kandinsky) и YandexART для создания изображений
+- **Зачем:** Уникальная фича для российского рынка. У конкурента YandexGPT уже есть
+- **Как:** Отдельный сервис или entity для генерации
+- **Сложность:** Средняя
+
+#### 5.7 Sub-entries (несколько агентов)
+- **Что:** Позволить создавать несколько conversation agents с разными моделями/промптами через одну интеграцию
+- **Зачем:** Паттерн из HA 2025.8+. Один агент для чата, другой для device control, третий для генерации
+- **Сложность:** Средняя
+
+#### 5.8 MCP (Model Context Protocol)
+- **Что:** Интеграция с MCP серверами для расширения возможностей агента
+- **Зачем:** Все official-интеграции поддерживают. Даёт доступ к новостям, todo-спискам, внешним данным
+- **Сложность:** Средняя-высокая
+
+### Приоритет: Низкий (nice to have)
+
+#### 5.9 Доступ к истории состояний
+- LLM анализирует тренды и прошлые события ("Какая была температура вчера?")
+
+#### 5.10 Telegram-бот
+- Использовать SmartChain как backend для Telegram (как у YandexGPT)
+
+#### 5.11 STT/TTS интеграция
+- Связка с Yandex SpeechKit или GigaChat TTS для полного voice pipeline на русском
+
+#### 5.12 Prompt caching
+- Кэширование промптов для ускорения повторных запросов (как в llama.cpp)
+
+#### 5.13 Multi-agent
+- Dispatcher agent маршрутизирует запросы между специализированными агентами
+
+#### 5.14 Skill-система
+- Загружаемые "навыки" для разных доменов (погода, расписание, рецепты)
+
+#### 5.15 Conversational follow-ups
+- LLM задаёт уточняющие вопросы, HA слушает ответ
+
+---
+
+## 6. Стратегическое позиционирование
+
+### Текущее УТП (Unique Selling Proposition)
+
+SmartChain — **единственная** HA интеграция, объединяющая GigaChat + YandexGPT + OpenAI в одном компоненте через LangChain. Это позволяет:
+- Переключаться между провайдерами без переустановки
+- Использовать единый интерфейс для разных LLM
+- Легко добавлять новые модели через LangChain экосистему
+
+### Слабые стороны
+
+- Нет управления устройствами — главный разрыв с конкурентами
+- Нет AI Task — не может использоваться в автоматизациях
+- Нет локальных моделей (Ollama) — зависимость от cloud API
+- Небольшое сообщество (15 stars vs 3k у Home-LLM)
+
+### Рекомендуемая дорожная карта
+
+```mermaid
+gantt
+ title SmartChain Roadmap
+ dateFormat YYYY-MM
+ section Высокий приоритет
+ Assist API + Device Control :2026-03, 2026-04
+ AI Task entity :2026-04, 2026-05
+ DeepSeek + Ollama провайдеры :2026-04, 2026-05
+ section Средний приоритет
+ Function calling :2026-05, 2026-06
+ Vision / мультимодальность :2026-06, 2026-07
+ Sub-entries :2026-06, 2026-07
+ Генерация изображений :2026-07, 2026-08
+ section Низкий приоритет
+ Multi-agent :2026-08, 2026-09
+ Telegram-бот :2026-09, 2026-10
+```
+
+### Целевая аудитория
+
+1. **Российские пользователи HA** — GigaChat + YandexGPT без VPN
+2. **Privacy-conscious** — Ollama + T-Pro/Qwen для полностью локального решения
+3. **Мультипровайдерные** — один компонент вместо нескольких интеграций
+4. **Разработчики** — LangChain экосистема для кастомизации
+
+---
+
+## 7. Источники
+
+### Официальная документация HA
+- https://www.home-assistant.io/blog/2025/09/11/ai-in-home-assistant/
+- https://developers.home-assistant.io/docs/core/llm/
+- https://www.home-assistant.io/integrations/openai_conversation/
+- https://www.home-assistant.io/integrations/anthropic/
+- https://www.home-assistant.io/integrations/google_generative_ai_conversation/
+- https://www.home-assistant.io/integrations/ollama/
+- https://www.home-assistant.io/integrations/open_router/
+- https://www.home-assistant.io/integrations/ai_task/
+- https://www.home-assistant.io/blog/2025/08/06/release-20258/
+- https://developers.home-assistant.io/docs/core/entity/ai-task/
+- https://developers.home-assistant.io/docs/core/entity/conversation
+
+### Custom-интеграции
+- https://github.com/jekalmin/extended_openai_conversation
+- https://github.com/black-roland/homeassistant-yandexgpt
+- https://github.com/black-roland/homeassistant-cloud-ru-ai
+- https://github.com/acon96/home-llm
+- https://github.com/valentinfrlch/ha-llmvision
+
+### Российские LLM
+- https://vc.ru/ai/2733649-rossiyskie-neuroseti-2026-modeli-i-servisy-dlya-biznesa
+- https://habr.com/ru/companies/tbank/articles/928956/
+- https://mts.ai/tech/mts-ai-releases-cotype-pro-2-second-generation-business-focused-llm/
+- https://www.tbank.ru/about/news/11122024-the-t-technologies-group-has-introduced-the-worlds-most-efficient-open-large-language-models-in-russian/
+
+### Китайские LLM
+- https://intuitionlabs.ai/articles/chinese-open-source-llms-2025
+- https://changelog.langchain.com/announcements/deepseek-integration-in-langchain
+- https://www.index.dev/blog/chinese-ai-models-deepseek
+
+### LangChain интеграции
+- https://docs.langchain.com/oss/python/integrations/providers/yandex
+- https://docs.langchain.com/oss/python/integrations/chat/deepseek
+- https://ollama.com/fixt/home-3b-v3
diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md
new file mode 100644
index 0000000..62079b3
--- /dev/null
+++ b/docs/DOCUMENTATION.md
@@ -0,0 +1,203 @@
+# SmartChain — Technical Documentation
+
+## Overview
+
+**SmartChain** is a custom component for [Home Assistant](https://www.home-assistant.io/) providing a voice/conversation assistant using multiple LLM providers via LangChain.
+
+- **Version:** 0.7.0
+- **Domain:** `smartchain`
+- **Integration type:** service
+- **IoT class:** cloud_polling
+- **Distribution:** [HACS](https://hacs.xyz/)
+
+## Architecture
+
+```mermaid
+flowchart TD
+ A[User] -->|Voice / Text| B[Home Assistant Voice Pipeline]
+ B --> C{Builtin Sentence Processor}
+ C -->|Recognized| D[HA Intent Handler]
+ C -->|Not recognized| E[SmartChainConversationEntity]
+ E --> F{LLM Engine}
+ F --> G[GigaChat API]
+ F --> H[YandexGPT API]
+ F --> I[OpenAI API]
+ G --> J[Response to user]
+ H --> J
+ I --> J
+ D --> J
+
+ K[HA Automation] -->|ai_task.generate_data| L[SmartChainAITaskEntity]
+ L --> F
+```
+
+### Key Components
+
+```mermaid
+classDiagram
+ class SmartChainConversationEntity {
+ +entry: ConfigEntry
+ +supported_languages: list
+ +_async_handle_message(user_input, chat_log) ConversationResult
+ }
+
+ class SmartChainAITaskEntity {
+ +entry: ConfigEntry
+ +_async_generate_data(task, chat_log) GenDataTaskResult
+ }
+
+ class ConfigFlow {
+ +async_step_user(user_input) ConfigFlowResult
+ +async_step_gigachat(user_input) ConfigFlowResult
+ +async_step_yandexgpt(user_input) ConfigFlowResult
+ +async_step_openai(user_input) ConfigFlowResult
+ }
+
+ class client_util {
+ +validate_client(hass, user_input)
+ +get_client(hass, engine, entry, common_args)
+ }
+
+ SmartChainConversationEntity --|> ConversationEntity
+ SmartChainAITaskEntity --|> AITaskEntity
+ SmartChainConversationEntity --> client_util
+ SmartChainAITaskEntity --> client_util
+```
+
+## File Structure
+
+```
+ha-smartchain/
+├── custom_components/
+│ └── smartchain/
+│ ├── __init__.py # Entry setup/unload, platform registration
+│ ├── conversation.py # ConversationEntity (streaming, tool calling)
+│ ├── ai_task.py # AITaskEntity (data generation)
+│ ├── config_flow.py # Config Flow + Options Flow
+│ ├── client_util.py # LLM client factory + validation
+│ ├── const.py # Constants, prompts, model lists
+│ ├── manifest.json # Integration metadata
+│ ├── strings.json # Base localization strings
+│ └── translations/
+│ ├── en.json # English localization
+│ └── ru.json # Russian localization
+├── tests/
+│ ├── conftest.py # Fixtures (hass, mock LLM client)
+│ ├── test_config_flow.py # Config Flow tests (11)
+│ ├── test_init.py # Conversation entity tests (19)
+│ ├── test_ai_task.py # AI Task entity tests (7)
+│ └── test_setup.py # Setup/unload tests (4)
+├── docs/
+│ ├── DOCUMENTATION.md # This file
+│ ├── COMPETITIVE_ANALYSIS.md # Competitive analysis
+│ └── ROADMAP.md # Development roadmap
+├── CLAUDE.md # Project rules for Claude Code
+├── CHANGELOG.md # Version changelog
+├── TODO.md # Task checklist
+├── README.md / README-ru.md # User documentation (EN/RU)
+├── pytest.ini # Pytest configuration
+├── requirements_test.txt # Test dependencies
+├── hacs.json # HACS metadata
+└── LICENSE # MIT license
+```
+
+## Supported LLM Providers
+
+| Provider | ID | Client Class | Auth Parameters |
+| ------------- | ----------- | -------------------------------------- | ------------------------ |
+| **GigaChat** | `gigachat` | `GigaChat` (langchain-gigachat) | `credentials` |
+| **YandexGPT** | `yandexgpt` | `ChatYandexGPT` (langchain-community) | `api_key` + `folder_id` |
+| **OpenAI** | `openai` | `ChatOpenAI` (langchain-openai) | `openai_api_key` |
+
+### Available Models
+
+- **GigaChat:** GigaChat, GigaChat:latest, GigaChat-Plus, GigaChat-Pro, GigaChat-Max
+- **YandexGPT:** YandexGPT, YandexGPT Lite, Summary
+- **OpenAI:** gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, gpt-4o, gpt-4o-mini, o3, o3-mini, o4-mini
+
+Custom model names are also supported.
+
+## Conversation Flow
+
+```mermaid
+flowchart TD
+ A[Message + ChatLog] --> B{Assist API configured?}
+ B -->|Yes| C[async_provide_llm_data - tools + prompt]
+ B -->|No| D[Manual Jinja2 prompt + device list]
+ C --> E{Builtin sentences?}
+ D --> F{Builtin sentences enabled?}
+ F -->|Yes| G[Try HA Default Agent]
+ F -->|No| H[Build LangChain messages]
+ E --> H
+ G --> I{Recognized?}
+ I -->|Yes| J[Return HA response]
+ I -->|No| H
+ H --> K[client.astream - streaming]
+ K --> L{Tool calls?}
+ L -->|Yes| M[Execute tools via HA]
+ M --> H
+ L -->|No| N[Return response]
+```
+
+### Streaming
+Responses stream token-by-token via `ChatLog.async_add_delta_content_stream()`. The `_async_langchain_stream()` generator converts LangChain `AIMessageChunk` to HA delta dicts.
+
+### Tool Calling (Assist API)
+When `llm_hass_api` is configured, HA tools (lights, switches, etc.) are converted to LangChain format via `_ha_tool_to_dict()` and bound to the client with `bind_tools()`. The tool calling loop runs up to `MAX_TOOL_ITERATIONS = 10` times.
+
+### AI Task Entity
+`SmartChainAITaskEntity` implements `ai_task.AITaskEntity` for use in automations via `ai_task.generate_data`. Supports:
+- Plain text generation
+- Structured output (JSON parsing with `task.structure`)
+- Tool calling (same as conversation entity)
+
+## Configuration Parameters
+
+### Data (set during installation)
+
+| Parameter | Key | Type | Description |
+| --------- | ----------- | ----- | ---------------------------------------- |
+| Engine | `engine` | `str` | LLM engine ID |
+| API Key | `api_key` | `str` | Authentication key |
+| Folder ID | `folder_id` | `str` | Yandex Cloud folder (YandexGPT only) |
+
+### Options (configurable after installation)
+
+| Parameter | Key | Type | Default | Description |
+| ---------------------- | -------------------------- | ---------- | -------- | ------------------------------------ |
+| Model (list) | `model` | `str` | `""` | Model from provider list |
+| Model (custom) | `model_user` | `str` | `""` | Custom model name |
+| Assist API | `llm_hass_api` | `list` | - | HA LLM API for device control |
+| Prompt | `prompt` | `template` | Default | System prompt (Jinja2) |
+| Temperature | `temperature` | `float` | `0.1` | Generation temperature |
+| Max Tokens | `max_tokens` | `int` | - | Max response tokens |
+| Builtin Sentences | `process_builtin_sentences`| `bool` | `True` | Try HA builtin handler first |
+| Chat History | `chat_history` | `bool` | `True` | Keep conversation history |
+| Profanity | `profanity` | `bool` | `False` | Profanity filter (GigaChat only) |
+| Verify SSL | `verify_ssl` | `bool` | `False` | SSL cert verification (GigaChat) |
+
+## Testing
+
+```bash
+pip install pytest-homeassistant-custom-component
+python3 -m pytest tests/ -v
+```
+
+### Test Coverage (41 tests)
+
+- **test_config_flow.py** — 11 tests (engine selection, full flows, error handling, skip validation)
+- **test_init.py** — 19 tests (conversation entity, streaming, tool calling, history, prompts)
+- **test_ai_task.py** — 7 tests (data generation, structured output, errors, tools)
+- **test_setup.py** — 4 tests (setup, unload, entity creation)
+
+## Dependencies
+
+| Package | Description |
+| -------------------------- | ------------------------------ |
+| `home-assistant-intents` | Language support |
+| `langchain-gigachat>=0.3.0`| GigaChat LLM client |
+| `langchain-openai>=0.3.0` | OpenAI LLM client |
+| `langchain-community>=0.4.0`| YandexGPT + LangChain utils |
+| `yandexcloud==0.295.0` | Yandex Cloud SDK |
+
+HA dependencies: `ai_task`, `conversation`
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
new file mode 100644
index 0000000..9d9a724
--- /dev/null
+++ b/docs/ROADMAP.md
@@ -0,0 +1,371 @@
+# SmartChain — Дорожная карта развития
+
+Дата: 2026-03-10 | Текущая версия: 0.6.0
+
+## Оглавление
+
+1. [Текущее состояние](#1-текущее-состояние)
+2. [Фаза 1 — Конкурентный паритет](#2-фаза-1--конкурентный-паритет-v07--v09)
+3. [Фаза 2 — Дифференциация](#3-фаза-2--дифференциация-v10--v13)
+4. [Фаза 3 — Лидерство](#4-фаза-3--лидерство-v14)
+5. [Технические детали по задачам](#5-технические-детали-по-задачам)
+
+---
+
+## 1. Текущее состояние
+
+### Реализовано (v0.1–v0.6)
+
+| Версия | Что сделано |
+|--------|-------------|
+| 0.1.x | Базовая интеграция: GigaChat + YandexGPT + OpenAI, Config/Options Flow, история диалогов, Jinja2 промпт |
+| 0.2.0 | Исправление блокировки event loop, deprecated API, утечки памяти, удаление Anyscale |
+| 0.2.1 | verify_ssl для GigaChat, обновление CI (Python 3.12, ruff) |
+| 0.3.0 | Миграция на `ConversationEntity` API, `conversation.py`, pytest (20 тестов) |
+| 0.4.0 | Миграция на ChatLog, langchain-gigachat/langchain-openai, CI pytest (26 тестов) |
+| 0.5.0 | Streaming ответов через `astream()` + `async_add_delta_content_stream()` (29 тестов) |
+| 0.6.0 | **Assist API** — управление устройствами через tool calling, LLM API selector, обновление промпта и моделей (34 теста) |
+
+### Текущая архитектура
+
+```mermaid
+graph TB
+ subgraph "SmartChain v0.6.0"
+ CF[Config Flow] --> CU[client_util.py]
+ CU --> GC[GigaChat]
+ CU --> YGP[YandexGPT]
+ CU --> OAI[OpenAI]
+
+ CE[ConversationEntity] --> CL[ChatLog]
+ CE --> ST[Streaming]
+ CE --> AA[Assist API]
+ AA --> TC[Tool Calling Loop]
+ TC --> BT[bind_tools]
+ end
+
+ subgraph "Home Assistant"
+ HA_LLM[HA LLM API] --> AA
+ HA_TOOLS[Intents & Services] --> HA_LLM
+ end
+```
+
+### Разрыв с конкурентами
+
+| Фича | Official HA | Extended OpenAI | YandexGPT | Home-LLM | SmartChain |
+|-------|:-----------:|:---------------:|:---------:|:--------:|:---------:|
+| Assist API / Device Control | + | + | + | + | **+ (v0.6)** |
+| AI Task entity | + | - | - | + | - |
+| Streaming | + | + | - | + | **+ (v0.5)** |
+| MCP | + | - | - | - | - |
+| Vision | + (OpenAI, Gemini) | - | - | - | - |
+| Генерация изображений | - | - | + | - | - |
+| Function calling (custom) | - | + | + | + | - |
+| Sub-entries | + | - | - | - | - |
+| Ollama / локальные модели | + | - | - | + | - |
+| Multi-agent | - | + | - | - | - |
+| Telegram-бот | - | - | + | - | - |
+| История состояний | - | + | - | - | - |
+
+---
+
+## 2. Фаза 1 — Конкурентный паритет (v0.7 – v0.9)
+
+Цель: закрыть критические разрывы с конкурентами, стать полноценным smart home agent.
+
+### v0.7 — AI Task entity
+
+**Приоритет:** Высокий
+**Сложность:** Низкая-средняя
+**Зависимости:** нет
+
+**Что:**
+- Добавить `AITaskEntity` с методом `_async_generate_data(task, chat_log)`
+- Позволяет использовать SmartChain в автоматизациях HA через `ai_task.generate_data`
+- Примеры: "Составь план уборки на основе загрязнённости комнат", "Проанализируй расход электричества за неделю"
+
+**Файлы:**
+- `custom_components/smartchain/ai_task.py` — новый файл с `SmartChainAITaskEntity`
+- `custom_components/smartchain/__init__.py` — добавить `Platform.AI_TASK`
+- `custom_components/smartchain/manifest.json` — добавить `"ai_task"` в dependencies
+- `tests/test_ai_task.py` — тесты
+
+**Реализация:**
+```python
+class SmartChainAITaskEntity(AITaskEntity):
+ async def _async_generate_data(
+ self, task: ai_task.GenData, chat_log: ChatLog
+ ) -> None:
+ # Предоставить LLM данные (tools, prompt)
+ await chat_log.async_provide_llm_data(...)
+ # Вызвать LLM через общую логику streaming + tool calling
+ # Результат записывается в chat_log автоматически
+```
+
+**Референсы:**
+- https://developers.home-assistant.io/docs/core/entity/ai-task/
+- `homeassistant.components.openai_conversation.ai_task`
+
+---
+
+### v0.8 — Новые провайдеры LLM (Ollama, DeepSeek, Anthropic)
+
+**Приоритет:** Высокий
+**Сложность:** Низкая
+**Зависимости:** нет
+
+**Что:**
+Добавить 3 новых провайдера через LangChain. Архитектура уже позволяет — нужно расширить `client_util.py` и Config Flow.
+
+#### 0.8.1 — Ollama (локальные модели)
+
+Открывает доступ ко всем локальным моделям: T-Pro 2.0, T-Lite, Qwen3, Llama, DeepSeek, Gemma, Phi, Home-3B.
+
+**Файлы:**
+- `const.py` — `ID_OLLAMA`, `UNIQUE_ID_OLLAMA`, модели, `CONF_BASE_URL`
+- `client_util.py` — `ChatOllama(model=..., base_url=...)`
+- `config_flow.py` — шаг `async_step_ollama` (base_url + model)
+- `manifest.json` — `langchain-ollama>=0.3.0`
+- `strings.json`, `translations/` — строки для Ollama
+
+**Config:**
+- `base_url` (по умолчанию `http://localhost:11434`)
+- `model` (текстовое поле, т.к. модели загружаются пользователем)
+
+#### 0.8.2 — DeepSeek
+
+Самый дешёвый cloud-провайдер. V3 для обычных задач, R1 для reasoning.
+
+**Файлы:**
+- `const.py` — `ID_DEEPSEEK`, модели (`deepseek-chat`, `deepseek-reasoner`)
+- `client_util.py` — `ChatDeepSeek(model=..., api_key=...)`
+- `config_flow.py` — шаг `async_step_deepseek`
+- `manifest.json` — `langchain-deepseek>=0.1.0`
+
+#### 0.8.3 — Anthropic (Claude)
+
+Для пользователей, которые хотят Claude через SmartChain.
+
+**Файлы:**
+- `const.py` — `ID_ANTHROPIC`, модели (`claude-sonnet-4-6`, `claude-haiku-4-5`)
+- `client_util.py` — `ChatAnthropic(model=..., api_key=...)`
+- `config_flow.py` — шаг `async_step_anthropic`
+- `manifest.json` — `langchain-anthropic>=0.3.0`
+
+---
+
+### v0.9 — Sub-entries (несколько агентов)
+
+**Приоритет:** Средний-высокий
+**Сложность:** Средняя
+**Зависимости:** v0.8 (больше пользы с несколькими провайдерами)
+
+**Что:**
+- Позволить создавать несколько conversation/AI task agents с разными моделями и промптами через одну интеграцию
+- Паттерн из HA 2025.8+ — все официальные интеграции перешли на sub-entries
+- Пример: "GigaChat Max для управления домом" + "GigaChat Lite для чата" + "DeepSeek R1 для аналитики"
+
+**Файлы:**
+- `config_flow.py` — переработка: корневая entry хранит credentials, sub-entries хранят модель/промпт/API
+- `__init__.py` — setup sub-entries
+- `conversation.py`, `ai_task.py` — привязка к sub-entry
+
+**Референсы:**
+- https://developers.home-assistant.io/docs/config_entries/subentries
+- `homeassistant.components.openai_conversation` (реализация через sub-entries)
+
+---
+
+## 3. Фаза 2 — Дифференциация (v1.0 – v1.3)
+
+Цель: добавить уникальные возможности, которых нет у большинства конкурентов.
+
+### v1.0 — Vision / мультимодальность
+
+**Приоритет:** Средний
+**Сложность:** Средняя
+**Зависимости:** v0.8 (нужны провайдеры с vision: GigaChat 2.0, OpenAI, Ollama)
+
+**Что:**
+- Отправка изображений с камер HA в LLM
+- Анализ: "Кто у двери?", "Что показывает камера во дворе?"
+- Поддержка: GigaChat 2.0 (мультимодальный), OpenAI GPT-4o/4.1, Ollama (LLaVA, Gemma Vision)
+
+**Реализация:**
+- Получение snapshot с камеры через `camera.async_get_image()`
+- Передача base64 изображения в LLM через LangChain `HumanMessage(content=[{"type": "image_url", ...}])`
+- Новый tool `get_camera_snapshot` или интеграция с Frigate events
+
+---
+
+### v1.1 — Генерация изображений (GigaChat Kandinsky + YandexART)
+
+**Приоритет:** Средний
+**Сложность:** Средняя
+**Зависимости:** нет
+
+**Что:**
+- GigaChat 2.0 имеет встроенную генерацию через Kandinsky
+- YandexGPT имеет YandexART API
+- Результат: `image` entity или сервис `smartchain.generate_image`
+
+**Реализация:**
+- Для GigaChat: уже поддерживается через API (модель сама решает, когда генерировать)
+- Для YandexART: отдельный API вызов
+- Сохранение результата в `/media/` или отправка через notify
+
+---
+
+### v1.2 — MCP (Model Context Protocol)
+
+**Приоритет:** Средний
+**Сложность:** Средняя-высокая
+**Зависимости:** v0.7 (Assist API)
+
+**Что:**
+- Подключение внешних MCP серверов как дополнительных tools для LLM
+- Все официальные интеграции HA поддерживают MCP с 2025.8
+- Примеры: новости, погода из внешних API, todo-листы, файловая система
+
+**Реализация:**
+- HA уже предоставляет MCP tools через `chat_log.llm_api.tools` — если пользователь настроил MCP в HA, tools автоматически доступны через Assist API
+- Дополнительно: собственный MCP client для LangChain tools (через `langchain-mcp`)
+
+---
+
+### v1.3 — Доступ к истории состояний
+
+**Приоритет:** Средний
+**Сложность:** Средняя
+**Зависимости:** v0.7 (tool calling)
+
+**Что:**
+- LLM может запрашивать историю состояний entity: "Какая температура была вчера?", "Когда последний раз открывали дверь?"
+- У Extended OpenAI Conversation это одна из ключевых фич
+
+**Реализация:**
+- Добавить custom tool `get_history(entity_id, start_time, end_time)`
+- Использовать `homeassistant.components.recorder` для получения данных
+- Форматирование результата в текст для LLM
+
+---
+
+## 4. Фаза 3 — Лидерство (v1.4+)
+
+Цель: уникальные фичи, которых нет ни у кого.
+
+### v1.4 — Multi-agent система
+
+**Приоритет:** Низкий
+**Сложность:** Высокая
+
+**Что:**
+- Dispatcher Agent маршрутизирует запросы между специализированными агентами
+- Агент управления устройствами (дешёвая быстрая модель)
+- Агент аналитики (reasoning модель)
+- Агент мультимедиа (vision модель)
+- Реализация через LangGraph
+
+---
+
+### v1.5 — Telegram-бот
+
+**Приоритет:** Низкий
+**Сложность:** Низкая
+
+**Что:**
+- Использование SmartChain как backend для Telegram-бота
+- Управление домом через Telegram
+- Отправка уведомлений с анализом камер
+
+---
+
+### v1.6 — STT/TTS интеграция
+
+**Приоритет:** Низкий
+**Сложность:** Средняя
+
+**Что:**
+- Связка с Yandex SpeechKit для STT/TTS на русском
+- GigaChat TTS (когда появится)
+- Полный voice pipeline: микрофон → STT → SmartChain → TTS → динамик
+
+---
+
+### v1.7 — Skill-система
+
+**Приоритет:** Низкий
+**Сложность:** Высокая
+
+**Что:**
+- Загружаемые "навыки" из директории (как у Extended OpenAI)
+- YAML-описание навыка: промпт + tools + trigger
+- Маркетплейс навыков (community-driven)
+
+---
+
+### v1.8 — Prompt caching
+
+**Приоритет:** Низкий
+**Сложность:** Низкая
+
+**Что:**
+- Кэширование системного промпта для ускорения повторных запросов
+- Экономия токенов при повторяющихся вызовах
+- Реализация: hash промпта → cached response для идентичных запросов
+
+---
+
+## 5. Технические детали по задачам
+
+### Зависимости для новых провайдеров
+
+| Провайдер | pip-пакет | LangChain класс | Min version |
+|-----------|-----------|-----------------|-------------|
+| Ollama | `langchain-ollama` | `ChatOllama` | >=0.3.0 |
+| DeepSeek | `langchain-deepseek` | `ChatDeepSeek` | >=0.1.0 |
+| Anthropic | `langchain-anthropic` | `ChatAnthropic` | >=0.3.0 |
+
+### Рекомендуемые локальные модели (для Ollama)
+
+| Модель | RAM | Лучше всего для | Язык |
+|--------|-----|-----------------|------|
+| Home-3B-v3 | ~2GB | Device control (97% точность) | EN |
+| T-Lite 7B | ~4GB | Русскоязычный чат | RU |
+| T-Pro 2.0 32B | ~18GB | Русский reasoning, tool calling | RU |
+| Qwen3 4B | ~3GB | Мультиязычный, tool calling | RU/EN/ZH |
+| Gemma 3 4B | ~3GB | Vision + чат | EN |
+
+### Целевые метрики по фазам
+
+| Фаза | Версия | Тестов | Провайдеров | Ключевая фича |
+|-------|--------|--------|-------------|---------------|
+| Текущая | 0.6.0 | 34 | 3 | Assist API |
+| Фаза 1 | 0.9.0 | ~60 | 6 | AI Task + Ollama + Sub-entries |
+| Фаза 2 | 1.3.0 | ~80 | 6 | Vision + MCP + Image Gen |
+| Фаза 3 | 1.8.0 | ~100 | 6+ | Multi-agent + Telegram + Skills |
+
+---
+
+```mermaid
+gantt
+ title SmartChain Roadmap 2026
+ dateFormat YYYY-MM
+ axisFormat %b %Y
+
+ section Фаза 1 — Паритет
+ v0.7 AI Task entity :a1, 2026-03, 2026-04
+ v0.8 Ollama + DeepSeek + Anthropic :a2, 2026-04, 2026-05
+ v0.9 Sub-entries :a3, 2026-05, 2026-06
+
+ section Фаза 2 — Дифференциация
+ v1.0 Vision :b1, 2026-06, 2026-07
+ v1.1 Image Generation :b2, 2026-07, 2026-08
+ v1.2 MCP :b3, 2026-07, 2026-08
+ v1.3 History Access :b4, 2026-08, 2026-09
+
+ section Фаза 3 — Лидерство
+ v1.4 Multi-agent :c1, 2026-09, 2026-11
+ v1.5 Telegram Bot :c2, 2026-10, 2026-11
+ v1.6 STT/TTS :c3, 2026-11, 2026-12
+ v1.7 Skill System :c4, 2026-12, 2027-02
+```
diff --git a/hacs.json b/hacs.json
index 797bcf4..1621fe2 100644
--- a/hacs.json
+++ b/hacs.json
@@ -1,4 +1,4 @@
{
- "name": "GigaChain",
- "render_readme" : true
+ "name": "SmartChain",
+ "render_readme": true
}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5d4e4fc
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,26 @@
+[project]
+name = "ha-smartchain"
+version = "0.7.0"
+description = "SmartChain — multi-provider LLM conversation agent for Home Assistant"
+readme = "README.md"
+license = "MIT"
+requires-python = ">=3.12"
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+
+[tool.ruff]
+target-version = "py312"
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "UP"]
+
+[dependency-groups]
+dev = [
+ "pytest-homeassistant-custom-component>=0.13",
+ "ruff>=0.15",
+ "langchain-core>=0.3",
+ "gigachat>=0.1",
+ "PyTurboJPEG>=2.0",
+]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+
diff --git a/requirements_test.txt b/requirements_test.txt
new file mode 100644
index 0000000..71f5999
--- /dev/null
+++ b/requirements_test.txt
@@ -0,0 +1 @@
+pytest-homeassistant-custom-component
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..54ffec7
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Tests for the SmartChain integration."""
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..46f7230
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,92 @@
+"""Fixtures for SmartChain tests."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+from langchain_core.messages import AIMessage, AIMessageChunk
+
+from custom_components.smartchain.const import (
+ CONF_API_KEY,
+ CONF_ENGINE,
+ DOMAIN,
+ ID_GIGACHAT,
+ ID_OPENAI,
+ ID_YANDEX_GPT,
+)
+
+MOCK_GIGACHAT_DATA = {
+ CONF_ENGINE: ID_GIGACHAT,
+ CONF_API_KEY: "test-credentials",
+}
+
+MOCK_YANDEXGPT_DATA = {
+ CONF_ENGINE: ID_YANDEX_GPT,
+ CONF_API_KEY: "test-api-key",
+ "folder_id": "test-folder-id",
+}
+
+MOCK_OPENAI_DATA = {
+ CONF_ENGINE: ID_OPENAI,
+ CONF_API_KEY: "test-openai-key",
+}
+
+
+@pytest.fixture(autouse=True)
+async def setup_ha_components(hass: HomeAssistant) -> None:
+ """Set up required HA components for all tests."""
+ assert await async_setup_component(hass, "homeassistant", {})
+ assert await async_setup_component(hass, "conversation", {})
+ await hass.async_block_till_done()
+
+
+async def _mock_astream(messages):
+ """Async generator that yields a single AIMessageChunk."""
+ yield AIMessageChunk(content="Test response from LLM")
+
+
+@pytest.fixture
+def mock_llm_client():
+ """Create a mock LLM client with astream support."""
+ client = MagicMock()
+ client.invoke.return_value = AIMessage(content="Test response from LLM")
+ client.astream = MagicMock(side_effect=_mock_astream)
+ return client
+
+
+@pytest.fixture
+def mock_config_entry():
+ """Create a mock config entry."""
+ entry = MagicMock(spec=ConfigEntry)
+ entry.entry_id = "test_entry_id"
+ entry.domain = DOMAIN
+ entry.data = dict(MOCK_GIGACHAT_DATA)
+ entry.options = {}
+ entry.runtime_data = None
+ entry.unique_id = "GigaChat"
+ entry.add_update_listener = MagicMock(return_value=lambda: None)
+ entry.async_on_unload = MagicMock()
+ return entry
+
+
+@pytest.fixture
+def mock_validate_client():
+ """Mock validate_client to skip actual API calls."""
+ with patch(
+ "custom_components.smartchain.config_flow.validate_client",
+ new_callable=AsyncMock,
+ ) as mock:
+ yield mock
+
+
+@pytest.fixture
+def mock_get_client(mock_llm_client):
+ """Mock get_client to return a fake LLM client."""
+ with patch(
+ "custom_components.smartchain.get_client",
+ new_callable=AsyncMock,
+ return_value=mock_llm_client,
+ ) as mock:
+ yield mock
diff --git a/tests/test_ai_task.py b/tests/test_ai_task.py
new file mode 100644
index 0000000..ae802e6
--- /dev/null
+++ b/tests/test_ai_task.py
@@ -0,0 +1,194 @@
+"""Tests for SmartChain AI Task entity."""
+
+from unittest.mock import MagicMock
+
+import pytest
+from homeassistant.components import ai_task
+from homeassistant.components.conversation.chat_log import (
+ AssistantContent,
+ SystemContent,
+ UserContent,
+)
+from homeassistant.exceptions import HomeAssistantError
+from langchain_core.messages import AIMessageChunk
+
+from custom_components.smartchain.ai_task import SmartChainAITaskEntity
+from custom_components.smartchain.const import CONF_ENGINE, ID_GIGACHAT
+
+
+def _make_chat_log(conversation_id="test-conv-id"):
+ """Create a mock ChatLog for AI Task tests."""
+ chat_log = MagicMock()
+ chat_log.conversation_id = conversation_id
+ chat_log.content = [
+ SystemContent(content="You are a Home Assistant expert."),
+ UserContent(content="Summarize the weather"),
+ ]
+ chat_log.llm_api = None
+ chat_log.unresponded_tool_results = False
+
+ async def _mock_add_delta_stream(agent_id, stream):
+ collected = ""
+ async for delta in stream:
+ if "content" in delta:
+ collected += delta["content"]
+ content = AssistantContent(agent_id=agent_id, content=collected)
+ chat_log.content.append(content)
+ yield content
+
+ chat_log.async_add_delta_content_stream = _mock_add_delta_stream
+ return chat_log
+
+
+def _make_gen_data_task(instructions="Summarize the weather", structure=None):
+ """Create a GenDataTask."""
+ return ai_task.GenDataTask(
+ name="test_task",
+ instructions=instructions,
+ structure=structure,
+ )
+
+
+@pytest.fixture
+def ai_task_entity(mock_llm_client):
+ """Create a SmartChainAITaskEntity."""
+ entry = MagicMock()
+ entry.entry_id = "test_entry"
+ entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"}
+ entry.options = {}
+ entry.runtime_data = mock_llm_client
+ ent = SmartChainAITaskEntity(entry)
+ ent.hass = MagicMock()
+ ent._attr_entity_id = "ai_task.smartchain_test"
+ return ent
+
+
+async def test_ai_task_entity_init(ai_task_entity) -> None:
+ """Test AI Task entity initialization."""
+ assert ai_task_entity._attr_unique_id == "test_entry_ai_task"
+ assert (
+ ai_task_entity._attr_supported_features
+ == ai_task.AITaskEntityFeature.GENERATE_DATA
+ )
+ assert ai_task_entity._attr_has_entity_name is True
+
+
+async def test_generate_data_basic(ai_task_entity) -> None:
+ """Test basic data generation returns text result."""
+ chat_log = _make_chat_log()
+ task = _make_gen_data_task()
+
+ result = await ai_task_entity._async_generate_data(task, chat_log)
+
+ assert isinstance(result, ai_task.GenDataTaskResult)
+ assert result.conversation_id == "test-conv-id"
+ assert result.data == "Test response from LLM"
+
+
+async def test_generate_data_structured_json(ai_task_entity) -> None:
+ """Test structured output parses JSON response."""
+ import voluptuous as vol
+
+ # Mock client to return JSON
+ async def _json_astream(messages):
+ yield AIMessageChunk(content='{"temperature": 22, "condition": "sunny"}')
+
+ ai_task_entity.entry.runtime_data.astream = MagicMock(side_effect=_json_astream)
+
+ chat_log = _make_chat_log()
+ task = _make_gen_data_task(
+ instructions="Get weather data",
+ structure=vol.Schema(
+ {
+ vol.Required("temperature"): int,
+ vol.Required("condition"): str,
+ }
+ ),
+ )
+
+ result = await ai_task_entity._async_generate_data(task, chat_log)
+
+ assert isinstance(result, ai_task.GenDataTaskResult)
+ assert result.data == {"temperature": 22, "condition": "sunny"}
+
+
+async def test_generate_data_structured_invalid_json(ai_task_entity) -> None:
+ """Test structured output raises error on invalid JSON."""
+ import voluptuous as vol
+
+ async def _bad_astream(messages):
+ yield AIMessageChunk(content="not json at all")
+
+ ai_task_entity.entry.runtime_data.astream = MagicMock(side_effect=_bad_astream)
+
+ chat_log = _make_chat_log()
+ task = _make_gen_data_task(
+ instructions="Get data",
+ structure=vol.Schema({vol.Required("key"): str}),
+ )
+
+ with pytest.raises(HomeAssistantError, match="Failed to parse"):
+ await ai_task_entity._async_generate_data(task, chat_log)
+
+
+async def test_generate_data_llm_error(ai_task_entity) -> None:
+ """Test that LLM errors are raised as HomeAssistantError."""
+
+ async def _error_astream(messages):
+ raise RuntimeError("LLM API down")
+ yield # noqa: F841
+
+ ai_task_entity.entry.runtime_data.astream = MagicMock(side_effect=_error_astream)
+
+ chat_log = _make_chat_log()
+ task = _make_gen_data_task()
+
+ with pytest.raises(HomeAssistantError, match="AI Task error"):
+ await ai_task_entity._async_generate_data(task, chat_log)
+
+
+async def test_generate_data_with_tools(ai_task_entity) -> None:
+ """Test that tools from llm_api are bound to client."""
+ import voluptuous as vol
+
+ class MockTool:
+ name = "HassTurnOn"
+ description = "Turn on"
+ parameters = vol.Schema({vol.Required("entity_id"): str})
+
+ mock_api = MagicMock()
+ mock_api.tools = [MockTool()]
+
+ chat_log = _make_chat_log()
+ chat_log.llm_api = mock_api
+
+ bound_client = MagicMock()
+
+ async def _bound_astream(messages):
+ yield AIMessageChunk(content="Done, light is on")
+
+ bound_client.astream = MagicMock(side_effect=_bound_astream)
+ ai_task_entity.entry.runtime_data.bind_tools = MagicMock(return_value=bound_client)
+
+ task = _make_gen_data_task(instructions="Turn on kitchen light")
+
+ result = await ai_task_entity._async_generate_data(task, chat_log)
+
+ ai_task_entity.entry.runtime_data.bind_tools.assert_called_once()
+ assert result.data == "Done, light is on"
+
+
+async def test_generate_data_empty_response(ai_task_entity) -> None:
+ """Test handling of empty LLM response."""
+
+ async def _empty_astream(messages):
+ yield AIMessageChunk(content="")
+
+ ai_task_entity.entry.runtime_data.astream = MagicMock(side_effect=_empty_astream)
+
+ chat_log = _make_chat_log()
+ task = _make_gen_data_task()
+
+ result = await ai_task_entity._async_generate_data(task, chat_log)
+
+ assert result.data == ""
diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py
new file mode 100644
index 0000000..d53fe4b
--- /dev/null
+++ b/tests/test_config_flow.py
@@ -0,0 +1,212 @@
+"""Tests for SmartChain config flow."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from gigachat.exceptions import ResponseError
+from homeassistant import config_entries
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+from httpx import ConnectError
+
+from custom_components.smartchain.const import (
+ CONF_API_KEY,
+ CONF_ENGINE,
+ CONF_FOLDER_ID,
+ CONF_SKIP_VALIDATION,
+ DOMAIN,
+ ID_GIGACHAT,
+ ID_OPENAI,
+ ID_YANDEX_GPT,
+)
+
+pytestmark = pytest.mark.usefixtures("enable_custom_integrations")
+
+
+async def test_step_user_shows_engine_form(hass: HomeAssistant) -> None:
+ """Test that the user step shows engine selection form."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+
+async def test_step_user_selects_gigachat(hass: HomeAssistant) -> None:
+ """Test selecting GigaChat engine shows API key form."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_ENGINE: ID_GIGACHAT}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == ID_GIGACHAT
+
+
+async def test_step_user_selects_yandexgpt(hass: HomeAssistant) -> None:
+ """Test selecting YandexGPT engine shows API key + folder form."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_ENGINE: ID_YANDEX_GPT}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == ID_YANDEX_GPT
+
+
+async def test_step_user_selects_openai(hass: HomeAssistant) -> None:
+ """Test selecting OpenAI engine shows API key form."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_ENGINE: ID_OPENAI}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == ID_OPENAI
+
+
+async def test_gigachat_full_flow(
+ hass: HomeAssistant, mock_validate_client: AsyncMock
+) -> None:
+ """Test full GigaChat config flow creates entry."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_ENGINE: ID_GIGACHAT}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_API_KEY: "test-credentials", CONF_SKIP_VALIDATION: False},
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "GigaChat"
+ assert result["data"][CONF_ENGINE] == ID_GIGACHAT
+ assert result["data"][CONF_API_KEY] == "test-credentials"
+
+
+async def test_yandexgpt_full_flow(
+ hass: HomeAssistant, mock_validate_client: AsyncMock
+) -> None:
+ """Test full YandexGPT config flow creates entry."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_ENGINE: ID_YANDEX_GPT}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_API_KEY: "test-api-key",
+ CONF_FOLDER_ID: "test-folder",
+ CONF_SKIP_VALIDATION: False,
+ },
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "YandexGPT"
+ assert result["data"][CONF_ENGINE] == ID_YANDEX_GPT
+
+
+async def test_openai_full_flow(
+ hass: HomeAssistant, mock_validate_client: AsyncMock
+) -> None:
+ """Test full OpenAI config flow creates entry."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_ENGINE: ID_OPENAI}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_API_KEY: "test-openai-key", CONF_SKIP_VALIDATION: False},
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "OpenAI"
+ assert result["data"][CONF_ENGINE] == ID_OPENAI
+
+
+async def test_gigachat_connect_error(hass: HomeAssistant) -> None:
+ """Test GigaChat config flow handles connection error."""
+ with patch(
+ "custom_components.smartchain.config_flow.validate_client",
+ side_effect=ConnectError("Connection failed"),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_ENGINE: ID_GIGACHAT}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_API_KEY: "bad-credentials", CONF_SKIP_VALIDATION: False},
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_gigachat_invalid_response(hass: HomeAssistant) -> None:
+ """Test GigaChat config flow handles invalid response."""
+ with patch(
+ "custom_components.smartchain.config_flow.validate_client",
+ side_effect=ResponseError("Unauthorized"),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_ENGINE: ID_GIGACHAT}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_API_KEY: "bad-credentials", CONF_SKIP_VALIDATION: False},
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {"base": "invalid_response"}
+
+
+async def test_gigachat_unknown_error(hass: HomeAssistant) -> None:
+ """Test GigaChat config flow handles unknown error."""
+ with patch(
+ "custom_components.smartchain.config_flow.validate_client",
+ side_effect=RuntimeError("Something unexpected"),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_ENGINE: ID_GIGACHAT}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_API_KEY: "bad-credentials", CONF_SKIP_VALIDATION: False},
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {"base": "unknown"}
+
+
+async def test_skip_validation(
+ hass: HomeAssistant,
+) -> None:
+ """Test config flow with skip_validation=True skips API call."""
+ with patch(
+ "custom_components.smartchain.config_flow.validate_client",
+ new_callable=AsyncMock,
+ ) as mock_validate:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_ENGINE: ID_GIGACHAT}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_API_KEY: "test-credentials", CONF_SKIP_VALIDATION: True},
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ mock_validate.assert_called_once()
diff --git a/tests/test_init.py b/tests/test_init.py
new file mode 100644
index 0000000..f822888
--- /dev/null
+++ b/tests/test_init.py
@@ -0,0 +1,520 @@
+"""Tests for SmartChain conversation entity."""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from homeassistant.components.conversation import ConversationInput, ConversationResult
+from homeassistant.components.conversation.chat_log import (
+ AssistantContent,
+ SystemContent,
+ ToolResultContent,
+ UserContent,
+)
+from homeassistant.core import Context, HomeAssistant
+from homeassistant.helpers import intent, llm
+from langchain_core.messages import (
+ AIMessage,
+ AIMessageChunk,
+ HumanMessage,
+ SystemMessage,
+ ToolMessage,
+)
+
+from custom_components.smartchain.conversation import (
+ SmartChainConversationEntity,
+ _async_langchain_stream,
+ _chatlog_to_langchain,
+ _ha_tool_to_dict,
+)
+from custom_components.smartchain.const import (
+ CONF_CHAT_HISTORY,
+ CONF_ENGINE,
+ CONF_LLM_HASS_API,
+ CONF_PROCESS_BUILTIN_SENTENCES,
+ CONF_PROMPT,
+ ID_GIGACHAT,
+)
+
+
+def _make_input(text="Hello, assistant!", conversation_id=None):
+ """Create a ConversationInput with correct signature."""
+ return ConversationInput(
+ text=text,
+ context=Context(),
+ conversation_id=conversation_id,
+ device_id=None,
+ satellite_id=None,
+ language="ru",
+ agent_id="test_agent",
+ )
+
+
+def _make_chat_log(conversation_id="test-conv-id", user_text="Hello, assistant!"):
+ """Create a mock ChatLog with proper content list and streaming support."""
+ chat_log = MagicMock()
+ chat_log.conversation_id = conversation_id
+ chat_log.content = [
+ SystemContent(content=""),
+ UserContent(content=user_text),
+ ]
+ chat_log.async_add_assistant_content_without_tools = MagicMock()
+ chat_log.llm_api = None
+ chat_log.unresponded_tool_results = False
+
+ async def _mock_add_delta_stream(agent_id, stream):
+ collected = ""
+ async for delta in stream:
+ if "content" in delta:
+ collected += delta["content"]
+ content = AssistantContent(agent_id=agent_id, content=collected)
+ chat_log.content.append(content)
+ yield content
+
+ chat_log.async_add_delta_content_stream = _mock_add_delta_stream
+ return chat_log
+
+
+@pytest.fixture
+def mock_entry(mock_llm_client):
+ """Create a mock config entry with runtime_data."""
+ entry = MagicMock()
+ entry.entry_id = "test_entry"
+ entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"}
+ entry.options = {
+ CONF_PROMPT: "You are a test assistant.",
+ CONF_CHAT_HISTORY: True,
+ CONF_PROCESS_BUILTIN_SENTENCES: False,
+ }
+ entry.runtime_data = mock_llm_client
+ return entry
+
+
+@pytest.fixture
+def entity(hass: HomeAssistant, mock_entry):
+ """Create a SmartChainConversationEntity."""
+ ent = SmartChainConversationEntity(mock_entry)
+ ent.hass = hass
+ return ent
+
+
+@pytest.fixture
+def user_input():
+ """Create a ConversationInput."""
+ return _make_input()
+
+
+@pytest.fixture
+def mock_chat_log():
+ """Create a mock ChatLog."""
+ return _make_chat_log()
+
+
+async def test_handle_message_basic(
+ hass: HomeAssistant, entity, user_input, mock_chat_log
+) -> None:
+ """Test basic _async_handle_message returns a response."""
+ result = await entity._async_handle_message(user_input, mock_chat_log)
+
+ assert isinstance(result, ConversationResult)
+ assert result.response.speech["plain"]["speech"] == "Test response from LLM"
+ assert result.conversation_id == mock_chat_log.conversation_id
+
+
+async def test_handle_message_sets_system_prompt(
+ hass: HomeAssistant, entity, user_input, mock_chat_log
+) -> None:
+ """Test that system prompt is set in ChatLog."""
+ await entity._async_handle_message(user_input, mock_chat_log)
+
+ assert isinstance(mock_chat_log.content[0], SystemContent)
+ assert "test assistant" in mock_chat_log.content[0].content
+
+
+async def test_handle_message_sends_correct_messages_to_llm(
+ hass: HomeAssistant, entity, user_input, mock_chat_log
+) -> None:
+ """Test that LLM receives correct LangChain messages from ChatLog."""
+ await entity._async_handle_message(user_input, mock_chat_log)
+
+ call_args = entity.entry.runtime_data.astream.call_args[0][0]
+ assert len(call_args) == 2 # system + human
+ assert isinstance(call_args[0], SystemMessage)
+ assert isinstance(call_args[1], HumanMessage)
+ assert call_args[1].content == "Hello, assistant!"
+
+
+async def test_handle_message_with_history(
+ hass: HomeAssistant, entity, user_input
+) -> None:
+ """Test that ChatLog history is converted to LangChain messages."""
+ chat_log = _make_chat_log()
+ chat_log.content = [
+ SystemContent(content=""),
+ UserContent(content="First message"),
+ AssistantContent(agent_id="test", content="First response"),
+ UserContent(content="Hello, assistant!"),
+ ]
+
+ await entity._async_handle_message(user_input, chat_log)
+
+ call_args = entity.entry.runtime_data.astream.call_args[0][0]
+ assert len(call_args) == 4 # system + human + ai + human
+ assert isinstance(call_args[0], SystemMessage)
+ assert isinstance(call_args[1], HumanMessage)
+ assert call_args[1].content == "First message"
+ assert isinstance(call_args[2], AIMessage)
+ assert call_args[2].content == "First response"
+ assert isinstance(call_args[3], HumanMessage)
+ assert call_args[3].content == "Hello, assistant!"
+
+
+async def test_handle_message_history_disabled(
+ hass: HomeAssistant, mock_llm_client, user_input
+) -> None:
+ """Test that only current message is sent when history disabled."""
+ entry = MagicMock()
+ entry.entry_id = "test_entry"
+ entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"}
+ entry.options = {
+ CONF_PROMPT: "Test prompt.",
+ CONF_CHAT_HISTORY: False,
+ CONF_PROCESS_BUILTIN_SENTENCES: False,
+ }
+ entry.runtime_data = mock_llm_client
+
+ ent = SmartChainConversationEntity(entry)
+ ent.hass = hass
+
+ chat_log = _make_chat_log()
+ chat_log.content = [
+ SystemContent(content=""),
+ UserContent(content="Old message"),
+ AssistantContent(agent_id="test", content="Old response"),
+ UserContent(content="Hello, assistant!"),
+ ]
+
+ await ent._async_handle_message(user_input, chat_log)
+
+ call_args = mock_llm_client.astream.call_args[0][0]
+ assert len(call_args) == 2
+ assert isinstance(call_args[0], SystemMessage)
+ assert isinstance(call_args[1], HumanMessage)
+ assert call_args[1].content == "Hello, assistant!"
+
+
+async def test_handle_message_llm_error(
+ hass: HomeAssistant, entity, user_input, mock_chat_log
+) -> None:
+ """Test _async_handle_message handles LLM errors gracefully."""
+
+ async def _error_stream(messages):
+ raise RuntimeError("API Error")
+ yield # noqa: F841
+
+ entity.entry.runtime_data.astream = MagicMock(side_effect=_error_stream)
+
+ result = await entity._async_handle_message(user_input, mock_chat_log)
+
+ assert isinstance(result, ConversationResult)
+ assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN
+ assert "API Error" in result.response.speech["plain"]["speech"]
+
+
+async def test_handle_message_with_builtin_not_recognized(
+ hass: HomeAssistant, mock_llm_client, user_input, mock_chat_log
+) -> None:
+ """Test delegates to HA default agent, falls back to LLM when not recognized."""
+ entry = MagicMock()
+ entry.entry_id = "test_entry"
+ entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"}
+ entry.options = {
+ CONF_PROMPT: "Test prompt.",
+ CONF_CHAT_HISTORY: True,
+ CONF_PROCESS_BUILTIN_SENTENCES: True,
+ }
+ entry.runtime_data = mock_llm_client
+
+ mock_default_response = MagicMock(spec=ConversationResult)
+ mock_default_response.response = MagicMock()
+ mock_default_response.response.intent = None
+
+ mock_default_agent = AsyncMock()
+ mock_default_agent.async_process.return_value = mock_default_response
+
+ ent = SmartChainConversationEntity(entry)
+ ent.hass = hass
+
+ with patch(
+ "homeassistant.components.conversation.agent_manager.async_get_agent",
+ return_value=mock_default_agent,
+ ):
+ result = await ent._async_handle_message(user_input, mock_chat_log)
+
+ mock_default_agent.async_process.assert_called_once()
+ assert result.response.speech["plain"]["speech"] == "Test response from LLM"
+
+
+async def test_handle_message_builtin_recognized(
+ hass: HomeAssistant, mock_llm_client, user_input, mock_chat_log
+) -> None:
+ """Test returns HA response when builtin sentence recognized."""
+ entry = MagicMock()
+ entry.entry_id = "test_entry"
+ entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"}
+ entry.options = {
+ CONF_PROMPT: "Test prompt.",
+ CONF_CHAT_HISTORY: True,
+ CONF_PROCESS_BUILTIN_SENTENCES: True,
+ }
+ entry.runtime_data = mock_llm_client
+
+ mock_intent_response = MagicMock()
+ mock_intent_response.intent = MagicMock()
+ mock_intent_response.speech = {"plain": {"speech": "HA handled this"}}
+
+ mock_default_response = MagicMock(spec=ConversationResult)
+ mock_default_response.response = mock_intent_response
+
+ mock_default_agent = AsyncMock()
+ mock_default_agent.async_process.return_value = mock_default_response
+
+ ent = SmartChainConversationEntity(entry)
+ ent.hass = hass
+
+ with patch(
+ "homeassistant.components.conversation.agent_manager.async_get_agent",
+ return_value=mock_default_agent,
+ ):
+ result = await ent._async_handle_message(user_input, mock_chat_log)
+
+ mock_llm_client.astream.assert_not_called()
+ assert result.response is mock_intent_response
+
+
+async def test_supported_languages(hass: HomeAssistant, entity) -> None:
+ """Test that supported_languages returns a list."""
+ languages = entity.supported_languages
+ assert isinstance(languages, list)
+ assert len(languages) > 0
+
+
+async def test_supports_streaming(entity) -> None:
+ """Test that entity declares streaming support."""
+ assert entity._attr_supports_streaming is True
+
+
+def test_chatlog_to_langchain() -> None:
+ """Test conversion of ChatLog content to LangChain messages."""
+ chat_log = MagicMock()
+ chat_log.content = [
+ SystemContent(content="System prompt"),
+ UserContent(content="User message"),
+ AssistantContent(agent_id="test", content="AI response"),
+ UserContent(content="Follow-up"),
+ ]
+
+ messages = _chatlog_to_langchain(chat_log)
+
+ assert len(messages) == 4
+ assert isinstance(messages[0], SystemMessage)
+ assert messages[0].content == "System prompt"
+ assert isinstance(messages[1], HumanMessage)
+ assert messages[1].content == "User message"
+ assert isinstance(messages[2], AIMessage)
+ assert messages[2].content == "AI response"
+ assert isinstance(messages[3], HumanMessage)
+ assert messages[3].content == "Follow-up"
+
+
+def test_chatlog_to_langchain_skips_empty_assistant() -> None:
+ """Test that assistant content without text is skipped."""
+ chat_log = MagicMock()
+ chat_log.content = [
+ SystemContent(content="Prompt"),
+ UserContent(content="Hello"),
+ AssistantContent(agent_id="test", content=None),
+ ]
+
+ messages = _chatlog_to_langchain(chat_log)
+
+ assert len(messages) == 2
+
+
+async def test_async_langchain_stream() -> None:
+ """Test that _async_langchain_stream converts LangChain chunks to HA deltas."""
+ client = MagicMock()
+
+ async def _fake_astream(messages):
+ yield AIMessageChunk(content="Hello")
+ yield AIMessageChunk(content=" world")
+
+ client.astream = _fake_astream
+
+ deltas = []
+ async for delta in _async_langchain_stream(client, []):
+ deltas.append(delta)
+
+ assert len(deltas) == 2
+ assert deltas[0] == {"role": "assistant", "content": "Hello"}
+ assert deltas[1] == {"content": " world"}
+
+
+async def test_async_langchain_stream_skips_empty_chunks() -> None:
+ """Test that empty chunks are skipped in stream conversion."""
+ client = MagicMock()
+
+ async def _fake_astream(messages):
+ yield AIMessageChunk(content="")
+ yield AIMessageChunk(content="data")
+
+ client.astream = _fake_astream
+
+ deltas = []
+ async for delta in _async_langchain_stream(client, []):
+ deltas.append(delta)
+
+ assert len(deltas) == 2
+ assert deltas[0] == {"role": "assistant"}
+ assert deltas[1] == {"content": "data"}
+
+
+def test_chatlog_to_langchain_with_tool_calls() -> None:
+ """Test conversion of ChatLog with tool calls and tool results."""
+ tool_input = llm.ToolInput(
+ tool_name="HassTurnOn",
+ tool_args={"entity_id": "light.kitchen"},
+ id="call_123",
+ )
+ chat_log = MagicMock()
+ chat_log.content = [
+ SystemContent(content="System prompt"),
+ UserContent(content="Turn on kitchen light"),
+ AssistantContent(
+ agent_id="test",
+ content="",
+ tool_calls=[tool_input],
+ ),
+ ToolResultContent(
+ agent_id="test",
+ tool_call_id="call_123",
+ tool_name="HassTurnOn",
+ tool_result={"success": True},
+ ),
+ AssistantContent(agent_id="test", content="Done!"),
+ ]
+
+ messages = _chatlog_to_langchain(chat_log)
+
+ assert len(messages) == 5
+ assert isinstance(messages[0], SystemMessage)
+ assert isinstance(messages[1], HumanMessage)
+ assert isinstance(messages[2], AIMessage)
+ assert len(messages[2].tool_calls) == 1
+ assert messages[2].tool_calls[0]["name"] == "HassTurnOn"
+ assert messages[2].tool_calls[0]["id"] == "call_123"
+ assert isinstance(messages[3], ToolMessage)
+ assert messages[3].tool_call_id == "call_123"
+ assert json.loads(messages[3].content) == {"success": True}
+ assert isinstance(messages[4], AIMessage)
+ assert messages[4].content == "Done!"
+
+
+def test_ha_tool_to_dict() -> None:
+ """Test conversion of HA llm.Tool to dict for LangChain."""
+ import voluptuous as vol
+
+ class MockTool(llm.Tool):
+ name = "HassTurnOn"
+ description = "Turn on a device"
+ parameters = vol.Schema(
+ {
+ vol.Required("entity_id"): str,
+ }
+ )
+
+ async def async_call(self, hass, tool_input, llm_context):
+ return {"success": True}
+
+ tool = MockTool()
+ result = _ha_tool_to_dict(tool)
+
+ assert result["name"] == "HassTurnOn"
+ assert result["description"] == "Turn on a device"
+ assert "properties" in result["parameters"]
+ assert "entity_id" in result["parameters"]["properties"]
+
+
+async def test_async_langchain_stream_with_tool_calls() -> None:
+ """Test that tool_calls from LLM are converted to HA ToolInput in stream."""
+ client = MagicMock()
+
+ async def _fake_astream(messages):
+ yield AIMessageChunk(
+ content="",
+ tool_calls=[
+ {
+ "id": "call_1",
+ "name": "HassTurnOn",
+ "args": {"entity_id": "light.kitchen"},
+ },
+ ],
+ )
+
+ client.astream = _fake_astream
+
+ deltas = []
+ async for delta in _async_langchain_stream(client, []):
+ deltas.append(delta)
+
+ assert len(deltas) == 1
+ assert deltas[0]["role"] == "assistant"
+ assert "tool_calls" in deltas[0]
+ assert len(deltas[0]["tool_calls"]) == 1
+ tc = deltas[0]["tool_calls"][0]
+ assert isinstance(tc, llm.ToolInput)
+ assert tc.tool_name == "HassTurnOn"
+ assert tc.tool_args == {"entity_id": "light.kitchen"}
+ assert tc.id == "call_1"
+
+
+async def test_handle_message_with_llm_api(
+ hass: HomeAssistant, mock_llm_client, user_input
+) -> None:
+ """Test _async_handle_message with LLM Hass API configured."""
+ entry = MagicMock()
+ entry.entry_id = "test_entry"
+ entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"}
+ entry.options = {
+ CONF_PROMPT: "Test prompt.",
+ CONF_CHAT_HISTORY: True,
+ CONF_PROCESS_BUILTIN_SENTENCES: False,
+ CONF_LLM_HASS_API: "assist",
+ }
+ entry.runtime_data = mock_llm_client
+
+ ent = SmartChainConversationEntity(entry)
+ ent.hass = hass
+
+ chat_log = _make_chat_log()
+
+ with patch.object(
+ chat_log,
+ "async_provide_llm_data",
+ new_callable=AsyncMock,
+ ) as mock_provide:
+ chat_log.llm_api = None
+ await ent._async_handle_message(user_input, chat_log)
+
+ mock_provide.assert_called_once()
+ assert mock_provide.call_args[0][1] == "assist"
+
+
+async def test_handle_message_no_llm_api_sets_prompt_manually(
+ hass: HomeAssistant, entity, user_input, mock_chat_log
+) -> None:
+ """Test that without LLM API configured, system prompt is set manually."""
+ await entity._async_handle_message(user_input, mock_chat_log)
+
+ assert isinstance(mock_chat_log.content[0], SystemContent)
+ assert "test assistant" in mock_chat_log.content[0].content
diff --git a/tests/test_setup.py b/tests/test_setup.py
new file mode 100644
index 0000000..84e2786
--- /dev/null
+++ b/tests/test_setup.py
@@ -0,0 +1,111 @@
+"""Tests for SmartChain integration setup and unload."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from homeassistant.core import HomeAssistant
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.smartchain.const import (
+ CONF_API_KEY,
+ CONF_ENGINE,
+ DOMAIN,
+ ID_GIGACHAT,
+ ID_OPENAI,
+)
+
+pytestmark = pytest.mark.usefixtures("enable_custom_integrations")
+
+
+@pytest.fixture
+def mock_gigachat_entry(hass: HomeAssistant):
+ """Create a mock GigaChat config entry."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_ENGINE: ID_GIGACHAT, CONF_API_KEY: "test-credentials"},
+ options={},
+ unique_id="GigaChat",
+ )
+ entry.add_to_hass(hass)
+ return entry
+
+
+@pytest.fixture
+def mock_openai_entry(hass: HomeAssistant):
+ """Create a mock OpenAI config entry."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_ENGINE: ID_OPENAI, CONF_API_KEY: "test-openai-key"},
+ options={},
+ unique_id="OpenAI",
+ )
+ entry.add_to_hass(hass)
+ return entry
+
+
+async def test_setup_entry_gigachat(
+ hass: HomeAssistant, mock_gigachat_entry, mock_llm_client
+) -> None:
+ """Test successful setup of GigaChat entry."""
+ with patch(
+ "custom_components.smartchain.get_client",
+ new_callable=AsyncMock,
+ return_value=mock_llm_client,
+ ):
+ result = await hass.config_entries.async_setup(mock_gigachat_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert result is True
+ assert mock_gigachat_entry.runtime_data is mock_llm_client
+
+
+async def test_setup_entry_openai(
+ hass: HomeAssistant, mock_openai_entry, mock_llm_client
+) -> None:
+ """Test successful setup of OpenAI entry."""
+ with patch(
+ "custom_components.smartchain.get_client",
+ new_callable=AsyncMock,
+ return_value=mock_llm_client,
+ ):
+ result = await hass.config_entries.async_setup(mock_openai_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert result is True
+ assert mock_openai_entry.runtime_data is mock_llm_client
+
+
+async def test_unload_entry(
+ hass: HomeAssistant, mock_gigachat_entry, mock_llm_client
+) -> None:
+ """Test unloading a config entry."""
+ with patch(
+ "custom_components.smartchain.get_client",
+ new_callable=AsyncMock,
+ return_value=mock_llm_client,
+ ):
+ await hass.config_entries.async_setup(mock_gigachat_entry.entry_id)
+ await hass.async_block_till_done()
+
+ result = await hass.config_entries.async_unload(mock_gigachat_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert result is True
+
+
+async def test_setup_creates_conversation_entity(
+ hass: HomeAssistant, mock_gigachat_entry, mock_llm_client
+) -> None:
+ """Test that setup creates a conversation entity."""
+ with patch(
+ "custom_components.smartchain.get_client",
+ new_callable=AsyncMock,
+ return_value=mock_llm_client,
+ ):
+ await hass.config_entries.async_setup(mock_gigachat_entry.entry_id)
+ await hass.async_block_till_done()
+
+ states = [s for s in hass.states.async_all() if s.domain == "conversation"]
+ assert len(states) >= 2
+ entity_ids = [s.entity_id for s in states]
+ assert any("smartchain" in eid for eid in entity_ids)