Обновлено для HTTPS: дефолтный URL https://ha.nasweb.ru, добавлен verify_ssl
This commit is contained in:
+6
-819
@@ -1,826 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
ha_url: str = Field(
|
||||||
# -*- coding: utf-8 -*-
|
default="https://ha.nasweb.ru",
|
||||||
"""
|
description="URL Home Assistant (например https://ha.nasweb.ru)",
|
||||||
title: Home Assistant Smart Control
|
|
||||||
description: Управление умным домом через Home Assistant REST API с интерпретацией естественного языка.
|
|
||||||
author: ChatGPT (refactored by MiniMax-M3)
|
|
||||||
version: 2.2.0
|
|
||||||
license: MIT
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import requests
|
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
# Логгер — выводит в консоль Open WebUI
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Tools:
|
|
||||||
# ========================================================================
|
|
||||||
# Конфигурация (Valves - задаётся в UI Open WebUI)
|
|
||||||
# ========================================================================
|
|
||||||
class Valves(BaseModel):
|
|
||||||
"""Конфигурация инструмента."""
|
|
||||||
|
|
||||||
ha_url: str = Field(
|
|
||||||
default="http://192.168.31.62:8123",
|
|
||||||
description="URL Home Assistant (например http://192.168.31.62:8123)",
|
|
||||||
)
|
)
|
||||||
ha_token: str = Field(
|
ha_token: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="Long-Lived Access Token из профиля HA",
|
description="Long-Lived Access Token из профиля HA",
|
||||||
)
|
)
|
||||||
request_timeout: int = Field(default=10, description="Таймаут HTTP-запроса, сек")
|
verify_ssl: bool = Field(
|
||||||
max_retries: int = Field(default=2, description="Количество ретраев при сетевых сбоях")
|
|
||||||
cache_ttl: int = Field(default=5, description="TTL кэша состояний HA, сек")
|
|
||||||
confirm_global_actions: bool = Field(
|
|
||||||
default=True,
|
|
||||||
description="Спрашивать подтверждение для команд, действующих на ВСЕ сущности домена",
|
|
||||||
)
|
|
||||||
dry_run: bool = Field(
|
|
||||||
default=False,
|
default=False,
|
||||||
description="Не выполнять команды, только показывать что БУДЕТ сделано",
|
description="Проверять SSL-сертификат HA (false для самоподписанных, например за reverse-proxy)",
|
||||||
)
|
)
|
||||||
|
request_timeout: int = Field(default=10, description="Таймаут HTTP-запроса, сек")
|
||||||
def __init__(self):
|
|
||||||
self.valves = self.Valves()
|
|
||||||
# Кэш состояний с TTL
|
|
||||||
self._states_cache: Optional[Tuple[float, List[Dict[str, Any]]]] = None
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Стоп-слова и ключевые слова
|
|
||||||
# ========================================================================
|
|
||||||
_STOP_WORDS = {
|
|
||||||
"включи", "выключи", "поставь", "установи", "сделай", "запусти",
|
|
||||||
"на", "в", "во", "мне", "пожалуйста", "давай", "ну",
|
|
||||||
"свет", "лампочк", "климат", "температуру", "шторы", "музыку",
|
|
||||||
"режим", "сцену", "сценарий", "уют", "уютный", "тплый", "теплый",
|
|
||||||
"градус", "градуса", "градусов", "открой", "закрой", "опусти", "подними",
|
|
||||||
"пауза", "играй", "останови", "включил", "выключил", "поставил",
|
|
||||||
"комнате", "квартире", "доме", "гостиной", "спальне", "кухне",
|
|
||||||
"детской", "ванной", "прихожей", "коридоре", "кабинете",
|
|
||||||
"и", "а", "но", "или", "же", "бы", "ли",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Русские числительные для парсинга
|
|
||||||
_RU_NUMS = {
|
|
||||||
"ноль": 0, "один": 1, "два": 2, "три": 3, "четыре": 4, "пять": 5,
|
|
||||||
"шесть": 6, "семь": 7, "восемь": 8, "девять": 9, "десять": 10,
|
|
||||||
"одиннадцать": 11, "двенадцать": 12, "тринадцать": 13, "четырнадцать": 14,
|
|
||||||
"пятнадцать": 15, "шестнадцать": 16, "семнадцать": 17, "восемнадцать": 18,
|
|
||||||
"девятнадцать": 19, "двадцать": 20, "двадцать один": 21, "двадцать два": 22,
|
|
||||||
"двадцать три": 23, "двадцать четыре": 24, "двадцать пять": 25,
|
|
||||||
"двадцать шесть": 26, "двадцать семь": 27, "двадцать восемь": 28,
|
|
||||||
"двадцать девять": 29, "тридцать": 30,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Категории устройств для мониторинга
|
|
||||||
_MONITOR_DOMAINS = {
|
|
||||||
"window_door": {
|
|
||||||
"domains": ["binary_sensor"],
|
|
||||||
"keywords": ["окн", "двер", "форточ", "калитк", "ворота", "гараж"],
|
|
||||||
"open_states": ("open", "on", "unlocked", "detected"),
|
|
||||||
"closed_states": ("closed", "off", "locked", "clear"),
|
|
||||||
"title": "Окна и двери",
|
|
||||||
},
|
|
||||||
"light_status": {
|
|
||||||
"domains": ["light"],
|
|
||||||
"keywords": ["свет", "лампочк", "люстр"],
|
|
||||||
"active_states": ("on",),
|
|
||||||
"title": "Свет",
|
|
||||||
},
|
|
||||||
"climate_status": {
|
|
||||||
"domains": ["climate"],
|
|
||||||
"keywords": ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"],
|
|
||||||
"title": "Климат",
|
|
||||||
},
|
|
||||||
"battery": {
|
|
||||||
"domains": ["sensor", "binary_sensor"],
|
|
||||||
"keywords": ["батарейк", "аккумулятор", "заряд"],
|
|
||||||
"title": "Батарейки",
|
|
||||||
},
|
|
||||||
"media_status": {
|
|
||||||
"domains": ["media_player"],
|
|
||||||
"keywords": ["музык", "медиа", "плеер", "колонк", "телевизор", "тв"],
|
|
||||||
"title": "Медиа",
|
|
||||||
},
|
|
||||||
"presence": {
|
|
||||||
"domains": ["person", "device_tracker"],
|
|
||||||
"keywords": ["кто", "дома", "присутств"],
|
|
||||||
"title": "Люди",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# HTTP / API
|
|
||||||
# ========================================================================
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
|
||||||
return {
|
|
||||||
"Authorization": "Bearer " + self.valves.ha_token,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
||||||
"""HTTP-запрос с ретраями и логированием."""
|
|
||||||
url = self.valves.ha_url + path
|
|
||||||
last_exc: Optional[Exception] = None
|
|
||||||
for attempt in range(1, self.valves.max_retries + 2):
|
|
||||||
try:
|
|
||||||
resp = requests.request(
|
|
||||||
method, url,
|
|
||||||
headers=self._headers(),
|
|
||||||
timeout=self.valves.request_timeout,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp
|
|
||||||
except requests.RequestException as e:
|
|
||||||
last_exc = e
|
|
||||||
logger.warning(
|
|
||||||
"HA %s %s failed (attempt %d): %s",
|
|
||||||
method, path, attempt, e,
|
|
||||||
)
|
|
||||||
if attempt <= self.valves.max_retries:
|
|
||||||
time.sleep(0.5 * attempt)
|
|
||||||
raise RuntimeError("Home Assistant недоступен: " + str(last_exc))
|
|
||||||
|
|
||||||
def _get_states(self, force: bool = False) -> List[Dict[str, Any]]:
|
|
||||||
"""Получить состояния HA с TTL-кэшем."""
|
|
||||||
now = time.time()
|
|
||||||
if (
|
|
||||||
not force
|
|
||||||
and self._states_cache is not None
|
|
||||||
and now - self._states_cache[0] < self.valves.cache_ttl
|
|
||||||
):
|
|
||||||
return self._states_cache[1]
|
|
||||||
|
|
||||||
resp = self._request("GET", "/api/states")
|
|
||||||
states = resp.json()
|
|
||||||
self._states_cache = (now, states)
|
|
||||||
return states
|
|
||||||
|
|
||||||
def _call_service(self, domain: str, service: str, data: Dict[str, Any]) -> bool:
|
|
||||||
"""Вызов сервиса HA. Возвращает True если выполнено, False если dry-run."""
|
|
||||||
logger.info(
|
|
||||||
"%s -> %s.%s %s",
|
|
||||||
"[DRY] " if self.valves.dry_run else "",
|
|
||||||
domain, service, data,
|
|
||||||
)
|
|
||||||
if self.valves.dry_run:
|
|
||||||
return False
|
|
||||||
self._request("POST", "/api/services/" + domain + "/" + service, json=data)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Поиск сущностей
|
|
||||||
# ========================================================================
|
|
||||||
def _tokenize(self, text: str) -> List[str]:
|
|
||||||
"""Токенизация: фильтр стоп-слов, приведение к нижнему регистру."""
|
|
||||||
text = text.lower().strip().replace("ё", "е")
|
|
||||||
tokens = re.findall(r"[а-яa-z0-9]+", text)
|
|
||||||
return [t for t in tokens if t not in self._STOP_WORDS and len(t) >= 2]
|
|
||||||
|
|
||||||
def _entity_text(self, s: Dict[str, Any]) -> str:
|
|
||||||
"""Полный текст сущности: id + friendly_name + area."""
|
|
||||||
attrs = s.get("attributes", {}) or {}
|
|
||||||
return " ".join([
|
|
||||||
s.get("entity_id", ""),
|
|
||||||
attrs.get("friendly_name", "") or "",
|
|
||||||
attrs.get("area", "") or "",
|
|
||||||
attrs.get("area_id", "") or "",
|
|
||||||
]).lower()
|
|
||||||
|
|
||||||
def _find_entities(
|
|
||||||
self,
|
|
||||||
keywords: List[str],
|
|
||||||
domain: Optional[str] = None,
|
|
||||||
require_all: bool = False,
|
|
||||||
) -> List[str]:
|
|
||||||
"""Поиск сущностей по ключевым словам."""
|
|
||||||
states = self._get_states()
|
|
||||||
result: List[str] = []
|
|
||||||
|
|
||||||
for s in states:
|
|
||||||
eid = s["entity_id"]
|
|
||||||
if domain and not eid.startswith(domain + "."):
|
|
||||||
continue
|
|
||||||
haystack = self._entity_text(s)
|
|
||||||
if not keywords:
|
|
||||||
if domain:
|
|
||||||
result.append(eid)
|
|
||||||
continue
|
|
||||||
matches = sum(1 for kw in keywords if kw in haystack)
|
|
||||||
if (require_all and matches == len(keywords)) or (not require_all and matches > 0):
|
|
||||||
result.append(eid)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _get_state(self, entity_id: str) -> Optional[str]:
|
|
||||||
"""Получить текущее состояние конкретной сущности."""
|
|
||||||
for s in self._get_states():
|
|
||||||
if s["entity_id"] == entity_id:
|
|
||||||
return s.get("state")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _attr(self, s: Dict[str, Any], key: str, default=None) -> Any:
|
|
||||||
"""Безопасное извлечение атрибута."""
|
|
||||||
return (s.get("attributes", {}) or {}).get(key, default)
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Парсинг чисел
|
|
||||||
# ========================================================================
|
|
||||||
def _extract_number(self, text: str, default: Optional[float] = None) -> Optional[float]:
|
|
||||||
"""Извлечь число из текста: арабские цифры или русские слова."""
|
|
||||||
# Арабские цифры
|
|
||||||
m = re.search(r"-?\d+(?:[.,]\d+)?", text)
|
|
||||||
if m:
|
|
||||||
return float(m.group(0).replace(",", "."))
|
|
||||||
# Русские слова
|
|
||||||
text_norm = text.lower().replace("ё", "е")
|
|
||||||
# Сначала длинные сочетания
|
|
||||||
for word in sorted(self._RU_NUMS.keys(), key=len, reverse=True):
|
|
||||||
if word in text_norm:
|
|
||||||
return float(self._RU_NUMS[word])
|
|
||||||
return default
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Форматирование ответа
|
|
||||||
# ========================================================================
|
|
||||||
def _format_entity(self, s: Dict[str, Any]) -> str:
|
|
||||||
"""Красиво отформатировать одну сущность для вывода."""
|
|
||||||
attrs = s.get("attributes", {}) or {}
|
|
||||||
fn = attrs.get("friendly_name", s["entity_id"])
|
|
||||||
state = s.get("state", "?")
|
|
||||||
unit = attrs.get("unit_of_measurement", "")
|
|
||||||
area = attrs.get("area", "") or attrs.get("area_id", "")
|
|
||||||
room = " (" + area + ")" if area else ""
|
|
||||||
return " * " + fn + room + ": " + str(state) + str(unit)
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Мониторинг (ответы на вопросы о состоянии дома)
|
|
||||||
# ========================================================================
|
|
||||||
def _monitor_windows_doors(self, tokens: List[str]) -> List[str]:
|
|
||||||
"""Какие окна/двери открыты."""
|
|
||||||
cfg = self._MONITOR_DOMAINS["window_door"]
|
|
||||||
states = self._get_states()
|
|
||||||
|
|
||||||
items: List[Tuple[Dict[str, Any], bool, bool]] = []
|
|
||||||
for s in states:
|
|
||||||
eid = s["entity_id"]
|
|
||||||
is_relevant_domain = eid.startswith("binary_sensor.")
|
|
||||||
device_class = self._attr(s, "device_class", "")
|
|
||||||
is_door_window = device_class in ("door", "window", "garage_door", "lock")
|
|
||||||
|
|
||||||
text = self._entity_text(s)
|
|
||||||
has_keyword = any(k in text for k in cfg["keywords"])
|
|
||||||
|
|
||||||
if not ((is_relevant_domain and is_door_window) or has_keyword):
|
|
||||||
continue
|
|
||||||
|
|
||||||
cur = s.get("state", "")
|
|
||||||
is_open = cur in cfg["open_states"]
|
|
||||||
is_closed = cur in cfg["closed_states"]
|
|
||||||
items.append((s, is_open, is_closed))
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
return ["датчики окон/дверей не найдены"]
|
|
||||||
|
|
||||||
open_items = [s for s, is_open, _ in items if is_open]
|
|
||||||
closed_items = [s for s, _, is_closed in items if is_closed]
|
|
||||||
unknown = [s for s, is_open, is_closed in items if not is_open and not is_closed]
|
|
||||||
|
|
||||||
lines: List[str] = []
|
|
||||||
if open_items:
|
|
||||||
lines.append("**Открыто:**")
|
|
||||||
for s in open_items:
|
|
||||||
lines.append(self._format_entity(s))
|
|
||||||
if closed_items:
|
|
||||||
lines.append("Закрыто: " + str(len(closed_items)) + " шт.")
|
|
||||||
if unknown:
|
|
||||||
lines.append("Неизвестно: " + str(len(unknown)) + " шт.")
|
|
||||||
|
|
||||||
# Фильтр по комнате если указан
|
|
||||||
if tokens:
|
|
||||||
filtered = []
|
|
||||||
for line in lines:
|
|
||||||
if any(t in line.lower() for t in tokens):
|
|
||||||
filtered.append(line)
|
|
||||||
if filtered:
|
|
||||||
return filtered
|
|
||||||
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def _monitor_lights(self, tokens: List[str]) -> List[str]:
|
|
||||||
"""Какой свет горит."""
|
|
||||||
cfg = self._MONITOR_DOMAINS["light_status"]
|
|
||||||
entities = self._find_entities(tokens if tokens else cfg["keywords"], "light")
|
|
||||||
|
|
||||||
if not entities:
|
|
||||||
return ["свет не найден"]
|
|
||||||
|
|
||||||
on_items: List[str] = []
|
|
||||||
off_items: List[str] = []
|
|
||||||
for eid in entities:
|
|
||||||
state = self._get_state(eid)
|
|
||||||
if state == "on":
|
|
||||||
on_items.append(eid)
|
|
||||||
else:
|
|
||||||
off_items.append(eid)
|
|
||||||
|
|
||||||
lines: List[str] = []
|
|
||||||
if on_items:
|
|
||||||
lines.append("Горит: " + str(len(on_items)) + " шт.")
|
|
||||||
for eid in on_items[:15]:
|
|
||||||
for s in self._get_states():
|
|
||||||
if s["entity_id"] == eid:
|
|
||||||
lines.append(self._format_entity(s))
|
|
||||||
break
|
|
||||||
if off_items:
|
|
||||||
lines.append("Выключено: " + str(len(off_items)) + " шт.")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def _monitor_climate(self, tokens: List[str]) -> List[str]:
|
|
||||||
"""Какая температура и состояние климата."""
|
|
||||||
cfg = self._MONITOR_DOMAINS["climate_status"]
|
|
||||||
entities = self._find_entities(tokens if tokens else cfg["keywords"], "climate")
|
|
||||||
|
|
||||||
if not entities:
|
|
||||||
# Если ничего не нашли по climate, ищем sensor.temperature
|
|
||||||
entities = self._find_entities(tokens if tokens else ["температур"], "sensor")
|
|
||||||
|
|
||||||
if not entities:
|
|
||||||
return ["климат-устройства не найдены"]
|
|
||||||
|
|
||||||
lines = ["**Климат:**"]
|
|
||||||
for eid in entities[:20]:
|
|
||||||
for s in self._get_states():
|
|
||||||
if s["entity_id"] == eid:
|
|
||||||
lines.append(self._format_entity(s))
|
|
||||||
break
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def _monitor_battery(self, tokens: List[str]) -> List[str]:
|
|
||||||
"""Какие батарейки садятся."""
|
|
||||||
states = self._get_states()
|
|
||||||
low_battery: List[Tuple[Dict[str, Any], float]] = []
|
|
||||||
|
|
||||||
for s in states:
|
|
||||||
eid = s["entity_id"]
|
|
||||||
device_class = self._attr(s, "device_class", "")
|
|
||||||
unit = self._attr(s, "unit_of_measurement", "")
|
|
||||||
|
|
||||||
is_battery = (
|
|
||||||
device_class == "battery"
|
|
||||||
or "battery" in eid
|
|
||||||
or unit == "%"
|
|
||||||
) and (
|
|
||||||
"battery" in eid
|
|
||||||
or device_class == "battery"
|
|
||||||
or "батарейк" in self._entity_text(s)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_battery:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
level = float(s.get("state", ""))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if level < 30:
|
|
||||||
low_battery.append((s, level))
|
|
||||||
|
|
||||||
if not low_battery:
|
|
||||||
return ["Все батарейки в норме (>=30%)"]
|
|
||||||
|
|
||||||
low_battery.sort(key=lambda x: x[1])
|
|
||||||
lines = ["**Низкий заряд:**"]
|
|
||||||
for s, level in low_battery[:15]:
|
|
||||||
lines.append(self._format_entity(s))
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def _monitor_media(self, tokens: List[str]) -> List[str]:
|
|
||||||
"""Состояние медиаплееров."""
|
|
||||||
cfg = self._MONITOR_DOMAINS["media_status"]
|
|
||||||
entities = self._find_entities(tokens if tokens else cfg["keywords"], "media_player")
|
|
||||||
|
|
||||||
if not entities:
|
|
||||||
return ["медиаплееры не найдены"]
|
|
||||||
|
|
||||||
playing: List[str] = []
|
|
||||||
idle: List[str] = []
|
|
||||||
for eid in entities:
|
|
||||||
state = self._get_state(eid)
|
|
||||||
(playing if state == "playing" else idle).append(eid)
|
|
||||||
|
|
||||||
lines = ["**Медиа:**"]
|
|
||||||
for eid in playing:
|
|
||||||
for s in self._get_states():
|
|
||||||
if s["entity_id"] == eid:
|
|
||||||
fn = self._attr(s, "friendly_name", eid)
|
|
||||||
media_title = self._attr(s, "media_title", "")
|
|
||||||
artist = self._attr(s, "media_artist", "")
|
|
||||||
extra = ""
|
|
||||||
if media_title:
|
|
||||||
extra = " - " + artist + " - " + media_title
|
|
||||||
lines.append(" > " + fn + extra)
|
|
||||||
break
|
|
||||||
for eid in idle:
|
|
||||||
for s in self._get_states():
|
|
||||||
if s["entity_id"] == eid:
|
|
||||||
lines.append(" || " + self._attr(s, "friendly_name", eid))
|
|
||||||
break
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def _monitor_presence(self, tokens: List[str]) -> List[str]:
|
|
||||||
"""Кто дома."""
|
|
||||||
states = self._get_states()
|
|
||||||
home: List[str] = []
|
|
||||||
away: List[str] = []
|
|
||||||
|
|
||||||
for s in states:
|
|
||||||
eid = s["entity_id"]
|
|
||||||
if not (eid.startswith("person.") or eid.startswith("device_tracker.")):
|
|
||||||
continue
|
|
||||||
name = self._attr(s, "friendly_name", eid)
|
|
||||||
state = s.get("state", "")
|
|
||||||
if state == "home":
|
|
||||||
home.append(name)
|
|
||||||
else:
|
|
||||||
away.append(name)
|
|
||||||
|
|
||||||
lines = ["**Люди дома:**"]
|
|
||||||
if home:
|
|
||||||
for n in home:
|
|
||||||
lines.append(" HOME " + n)
|
|
||||||
if away:
|
|
||||||
lines.append(" AWAY Не дома: " + ", ".join(away))
|
|
||||||
if not home and not away:
|
|
||||||
lines.append(" (информация отсутствует)")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Главная точка входа
|
|
||||||
# ========================================================================
|
|
||||||
def smart(self, command: str) -> str:
|
|
||||||
"""
|
|
||||||
Принимает команду на естественном языке и управляет домом / отвечает на вопросы.
|
|
||||||
|
|
||||||
Управление:
|
|
||||||
- включи свет в спальне
|
|
||||||
- выключи весь свет
|
|
||||||
- поставь 23 градуса в детской
|
|
||||||
- открой шторы в гостиной
|
|
||||||
- запусти сцену вечерний отдых
|
|
||||||
- пауза музыки
|
|
||||||
|
|
||||||
Мониторинг:
|
|
||||||
- какие окна открыты?
|
|
||||||
- что горит лишнего?
|
|
||||||
- какая температура в спальне?
|
|
||||||
- батарейки садятся?
|
|
||||||
- кто дома?
|
|
||||||
"""
|
|
||||||
if not self.valves.ha_token:
|
|
||||||
return (
|
|
||||||
"Не задан HA Token. Укажите его в настройках инструмента (Valves) "
|
|
||||||
"в интерфейсе Open WebUI."
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd = command.lower().strip()
|
|
||||||
tokens = self._tokenize(cmd)
|
|
||||||
actions: List[str] = []
|
|
||||||
warnings: List[str] = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# ============================================================
|
|
||||||
# Режим мониторинга (вопросы о состоянии)
|
|
||||||
# ============================================================
|
|
||||||
is_question = (
|
|
||||||
cmd.startswith(("как", "сколько", "что", "кто", "где", "какая", "какой", "какое", "какие"))
|
|
||||||
or "?" in cmd
|
|
||||||
or "сейчас" in cmd
|
|
||||||
or "состоян" in cmd
|
|
||||||
or "статус" in cmd
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_question:
|
|
||||||
matched = False
|
|
||||||
|
|
||||||
if any(k in cmd for k in ["окн", "двер", "форточ", "калитк", "ворота", "гараж"]):
|
|
||||||
actions.extend(self._monitor_windows_doors(tokens))
|
|
||||||
matched = True
|
|
||||||
|
|
||||||
elif any(k in cmd for k in ["свет", "лампочк", "люстр"]) and (
|
|
||||||
"горит" in cmd or "включ" in cmd or "лишн" in cmd
|
|
||||||
):
|
|
||||||
actions.extend(self._monitor_lights(tokens))
|
|
||||||
matched = True
|
|
||||||
|
|
||||||
elif any(k in cmd for k in ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"]):
|
|
||||||
actions.extend(self._monitor_climate(tokens))
|
|
||||||
matched = True
|
|
||||||
|
|
||||||
elif "батарейк" in cmd or "заряд" in cmd:
|
|
||||||
actions.extend(self._monitor_battery(tokens))
|
|
||||||
matched = True
|
|
||||||
|
|
||||||
elif any(k in cmd for k in ["музык", "медиа", "плеер", "колонк", "телевизор"]):
|
|
||||||
actions.extend(self._monitor_media(tokens))
|
|
||||||
matched = True
|
|
||||||
|
|
||||||
elif "дома" in cmd or "присутств" in cmd:
|
|
||||||
actions.extend(self._monitor_presence(tokens))
|
|
||||||
matched = True
|
|
||||||
|
|
||||||
if not matched:
|
|
||||||
# Общий статус: топ устройств по доменам
|
|
||||||
actions.append("**Общий статус дома:**")
|
|
||||||
actions.extend(self._monitor_lights([]))
|
|
||||||
actions.extend(self._monitor_climate([]))
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Режим управления (команды действия)
|
|
||||||
# ============================================================
|
|
||||||
else:
|
|
||||||
actions.extend(self._handle_control(cmd, tokens, warnings))
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Итог
|
|
||||||
# ============================================================
|
|
||||||
if not actions:
|
|
||||||
hint = (
|
|
||||||
"Примеры команд:\n"
|
|
||||||
" - включи свет в спальне\n"
|
|
||||||
" - выключи весь свет\n"
|
|
||||||
" - поставь 23 градуса в детской\n"
|
|
||||||
" - открой шторы в гостиной\n"
|
|
||||||
" - какие окна открыты?\n"
|
|
||||||
" - какая температура в спальне?\n"
|
|
||||||
" - кто дома?"
|
|
||||||
)
|
|
||||||
if warnings:
|
|
||||||
return "Не удалось выполнить. " + ". ".join(warnings) + "\n\n" + hint
|
|
||||||
return "Не понял команду.\n\n" + hint
|
|
||||||
|
|
||||||
prefix = "[DRY-RUN] " if self.valves.dry_run else ""
|
|
||||||
result = prefix + "\n".join(actions)
|
|
||||||
if warnings:
|
|
||||||
result += "\n\nWARN: " + "; ".join(warnings)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("smart() failed")
|
|
||||||
return "Ошибка: " + str(e)
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Обработка команд управления
|
|
||||||
# ========================================================================
|
|
||||||
def _handle_control(
|
|
||||||
self, cmd: str, tokens: List[str], warnings: List[str]
|
|
||||||
) -> List[str]:
|
|
||||||
"""Обработка команд действия (вкл/выкл/установить/открыть/...)."""
|
|
||||||
actions: List[str] = []
|
|
||||||
|
|
||||||
# Свет
|
|
||||||
if "свет" in cmd or "лампочк" in cmd:
|
|
||||||
entities = self._find_entities(tokens, "light")
|
|
||||||
if not entities:
|
|
||||||
warnings.append("светильники не найдены")
|
|
||||||
else:
|
|
||||||
turn_off = any(w in cmd for w in ("выключ", "отключ", "погас"))
|
|
||||||
brightness = self._extract_number(cmd)
|
|
||||||
is_global = not any(
|
|
||||||
t in cmd for t in [
|
|
||||||
"спальн", "гостин", "кухн", "детск", "ванн",
|
|
||||||
"прихож", "коридор", "кабинет", "зал",
|
|
||||||
]
|
|
||||||
) and len(entities) > 1
|
|
||||||
|
|
||||||
if is_global and self.valves.confirm_global_actions and not self.valves.dry_run:
|
|
||||||
return [
|
|
||||||
"Команда подействует на ВСЕ светильники ("
|
|
||||||
+ str(len(entities))
|
|
||||||
+ " шт.). Укажите комнату (напр. 'в спальне') или подтвердите."
|
|
||||||
]
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for e in entities:
|
|
||||||
cur = self._get_state(e)
|
|
||||||
if turn_off:
|
|
||||||
if cur == "off":
|
|
||||||
continue
|
|
||||||
self._call_service("light", "turn_off", {"entity_id": e})
|
|
||||||
else:
|
|
||||||
data: Dict[str, Any] = {"entity_id": e}
|
|
||||||
if brightness is not None:
|
|
||||||
if brightness <= 100:
|
|
||||||
data["brightness"] = int(brightness * 2.55)
|
|
||||||
else:
|
|
||||||
data["brightness"] = int(brightness)
|
|
||||||
if "уют" in cmd or "тплый" in cmd or "тепл" in cmd:
|
|
||||||
data["color_temp"] = 320
|
|
||||||
elif "холод" in cmd or "бел" in cmd:
|
|
||||||
data["color_temp"] = 250
|
|
||||||
self._call_service("light", "turn_on", data)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
if count:
|
|
||||||
label = "выключен" if turn_off else "включён"
|
|
||||||
actions.append("свет " + label + " (" + str(count) + " шт.)")
|
|
||||||
|
|
||||||
# Розетки / выключатели
|
|
||||||
if any(k in cmd for k in ["розетк", "выключател", "устройств"]):
|
|
||||||
entities = self._find_entities(tokens, "switch")
|
|
||||||
if not entities:
|
|
||||||
warnings.append("розетки не найдены")
|
|
||||||
else:
|
|
||||||
turn_off = any(w in cmd for w in ("выключ", "отключ"))
|
|
||||||
count = 0
|
|
||||||
for e in entities:
|
|
||||||
cur = self._get_state(e)
|
|
||||||
if turn_off and cur == "off":
|
|
||||||
continue
|
|
||||||
if not turn_off and cur == "on":
|
|
||||||
continue
|
|
||||||
self._call_service(
|
|
||||||
"switch",
|
|
||||||
"turn_off" if turn_off else "turn_on",
|
|
||||||
{"entity_id": e},
|
|
||||||
)
|
|
||||||
count += 1
|
|
||||||
if count:
|
|
||||||
label = "выключены" if turn_off else "включены"
|
|
||||||
actions.append("розетки " + label + " (" + str(count) + " шт.)")
|
|
||||||
|
|
||||||
# Климат
|
|
||||||
if any(k in cmd for k in ["температур", "градус", "тепл", "холод", "климат"]):
|
|
||||||
temp = self._extract_number(cmd, default=22)
|
|
||||||
entities = self._find_entities(tokens, "climate")
|
|
||||||
if not entities:
|
|
||||||
warnings.append("климат-устройства не найдены")
|
|
||||||
else:
|
|
||||||
count = 0
|
|
||||||
for e in entities:
|
|
||||||
self._call_service(
|
|
||||||
"climate",
|
|
||||||
"set_temperature",
|
|
||||||
{"entity_id": e, "temperature": temp},
|
|
||||||
)
|
|
||||||
count += 1
|
|
||||||
if count:
|
|
||||||
actions.append("установлено " + str(temp) + "C (" + str(count) + " шт.)")
|
|
||||||
|
|
||||||
# Режим работы (нагрев/охлаждение/авто)
|
|
||||||
if "нагрев" in cmd:
|
|
||||||
entities = self._find_entities(tokens, "climate")
|
|
||||||
for e in entities:
|
|
||||||
self._call_service(
|
|
||||||
"climate", "set_hvac_mode",
|
|
||||||
{"entity_id": e, "hvac_mode": "heat"},
|
|
||||||
)
|
|
||||||
actions.append("режим нагрева")
|
|
||||||
elif "охлажд" in cmd or "холод" in cmd:
|
|
||||||
entities = self._find_entities(tokens, "climate")
|
|
||||||
for e in entities:
|
|
||||||
self._call_service(
|
|
||||||
"climate", "set_hvac_mode",
|
|
||||||
{"entity_id": e, "hvac_mode": "cool"},
|
|
||||||
)
|
|
||||||
actions.append("режим охлаждения")
|
|
||||||
|
|
||||||
# Медиа
|
|
||||||
if any(k in cmd for k in ["музык", "медиа", "плеер", "колонк"]):
|
|
||||||
entities = self._find_entities(tokens, "media_player")
|
|
||||||
if not entities:
|
|
||||||
warnings.append("медиаплееры не найдены")
|
|
||||||
else:
|
|
||||||
count = 0
|
|
||||||
if any(w in cmd for w in ("пауза", "стоп", "останов")):
|
|
||||||
for e in entities:
|
|
||||||
if self._get_state(e) in ("playing", "paused"):
|
|
||||||
self._call_service("media_player", "media_pause", {"entity_id": e})
|
|
||||||
count += 1
|
|
||||||
if count:
|
|
||||||
actions.append("пауза (" + str(count) + " шт.)")
|
|
||||||
|
|
||||||
elif "дальше" in cmd or "следующ" in cmd:
|
|
||||||
for e in entities:
|
|
||||||
self._call_service("media_player", "media_next_track", {"entity_id": e})
|
|
||||||
count += 1
|
|
||||||
if count:
|
|
||||||
actions.append("следующий трек")
|
|
||||||
|
|
||||||
elif "назад" in cmd or "предыдущ" in cmd:
|
|
||||||
for e in entities:
|
|
||||||
self._call_service("media_player", "media_previous_track", {"entity_id": e})
|
|
||||||
count += 1
|
|
||||||
if count:
|
|
||||||
actions.append("предыдущий трек")
|
|
||||||
|
|
||||||
elif "громче" in cmd or "тише" in cmd:
|
|
||||||
for e in entities:
|
|
||||||
self._call_service(
|
|
||||||
"media_player",
|
|
||||||
"volume_up" if "громче" in cmd else "volume_down",
|
|
||||||
{"entity_id": e},
|
|
||||||
)
|
|
||||||
count += 1
|
|
||||||
if count:
|
|
||||||
actions.append("громкость (" + str(count) + " шт.)")
|
|
||||||
|
|
||||||
else:
|
|
||||||
for e in entities:
|
|
||||||
if self._get_state(e) != "playing":
|
|
||||||
self._call_service("media_player", "media_play", {"entity_id": e})
|
|
||||||
count += 1
|
|
||||||
if count:
|
|
||||||
actions.append("воспроизведение (" + str(count) + " шт.)")
|
|
||||||
|
|
||||||
# Шторы / жалюзи
|
|
||||||
if any(k in cmd for k in ["штор", "жалюз", "роллет", "гардин"]):
|
|
||||||
entities = self._find_entities(tokens, "cover")
|
|
||||||
if not entities:
|
|
||||||
warnings.append("шторы не найдены")
|
|
||||||
else:
|
|
||||||
is_close = any(w in cmd for w in ("закр", "опуст", "спуст"))
|
|
||||||
count = 0
|
|
||||||
for e in entities:
|
|
||||||
if is_close:
|
|
||||||
self._call_service("cover", "close_cover", {"entity_id": e})
|
|
||||||
else:
|
|
||||||
self._call_service("cover", "open_cover", {"entity_id": e})
|
|
||||||
count += 1
|
|
||||||
if count:
|
|
||||||
label = "закрыты" if is_close else "открыты"
|
|
||||||
actions.append("шторы " + label + " (" + str(count) + " шт.)")
|
|
||||||
|
|
||||||
# Замки
|
|
||||||
if any(k in cmd for k in ["замок", "заблокир", "закрыт"]):
|
|
||||||
entities = self._find_entities(tokens, "lock")
|
|
||||||
if entities:
|
|
||||||
is_lock = "закры" in cmd or "заблок" in cmd
|
|
||||||
for e in entities:
|
|
||||||
self._call_service(
|
|
||||||
"lock",
|
|
||||||
"lock" if is_lock else "unlock",
|
|
||||||
{"entity_id": e},
|
|
||||||
)
|
|
||||||
actions.append(
|
|
||||||
"замки " + ("закрыты" if is_lock else "открыты") + " (" + str(len(entities)) + " шт.)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Вентиляторы
|
|
||||||
if "вентилятор" in cmd or "кулер" in cmd:
|
|
||||||
entities = self._find_entities(tokens, "fan")
|
|
||||||
if entities:
|
|
||||||
is_off = "выключ" in cmd
|
|
||||||
for e in entities:
|
|
||||||
self._call_service(
|
|
||||||
"fan",
|
|
||||||
"turn_off" if is_off else "turn_on",
|
|
||||||
{"entity_id": e},
|
|
||||||
)
|
|
||||||
actions.append(
|
|
||||||
"вентиляторы " + ("выключены" if is_off else "включены") + " (" + str(len(entities)) + " шт.)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Пылесосы
|
|
||||||
if "пылесос" in cmd or "робот" in cmd:
|
|
||||||
entities = self._find_entities(tokens, "vacuum")
|
|
||||||
if entities:
|
|
||||||
if "пауза" in cmd:
|
|
||||||
for e in entities:
|
|
||||||
self._call_service("vacuum", "pause", {"entity_id": e})
|
|
||||||
actions.append("пылесос пауза")
|
|
||||||
elif "стоп" in cmd or "верн" in cmd:
|
|
||||||
for e in entities:
|
|
||||||
self._call_service("vacuum", "stop", {"entity_id": e})
|
|
||||||
actions.append("пылесос стоп")
|
|
||||||
elif "уборк" in cmd or "запуст" in cmd:
|
|
||||||
for e in entities:
|
|
||||||
self._call_service("vacuum", "start", {"entity_id": e})
|
|
||||||
actions.append("пылесос уборка")
|
|
||||||
|
|
||||||
# Сцены / скрипты
|
|
||||||
if any(k in cmd for k in ["сцен", "режим", "запуст"]):
|
|
||||||
all_scripts = self._find_entities([], "script")
|
|
||||||
matched: List[str] = []
|
|
||||||
for s in all_scripts:
|
|
||||||
sl = s.lower()
|
|
||||||
for t in tokens:
|
|
||||||
if len(t) >= 3 and t in sl:
|
|
||||||
matched.append(s)
|
|
||||||
break
|
|
||||||
if matched:
|
|
||||||
for s in matched:
|
|
||||||
self._call_service("script", "turn_on", {"entity_id": s})
|
|
||||||
actions.append("SCRIPT " + s)
|
|
||||||
else:
|
|
||||||
warnings.append("подходящая сцена не найдена среди " + str(len(all_scripts)))
|
|
||||||
|
|
||||||
return actions
|
|
||||||
Reference in New Issue
Block a user