v2.2.2: полное восстановление файла (предыдущая версия была сломана из-за update_file)
This commit is contained in:
@@ -0,0 +1,785 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
title: Home Assistant Smart Control
|
||||||
|
description: Управление умным домом через Home Assistant REST API с интерпретацией естественного языка.
|
||||||
|
author: ChatGPT (refactored by MiniMax-M3)
|
||||||
|
version: 2.2.2
|
||||||
|
license: MIT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Tools:
|
||||||
|
# ========================================================================
|
||||||
|
# Конфигурация (Valves - задаётся в UI Open WebUI)
|
||||||
|
# ========================================================================
|
||||||
|
class Valves(BaseModel):
|
||||||
|
ha_url: str = Field(
|
||||||
|
default="https://ha.nasweb.ru",
|
||||||
|
description="URL Home Assistant (например https://ha.nasweb.ru)",
|
||||||
|
)
|
||||||
|
ha_token: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Long-Lived Access Token из профиля HA",
|
||||||
|
)
|
||||||
|
verify_ssl: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Проверять SSL-сертификат HA (false для самоподписанных)",
|
||||||
|
)
|
||||||
|
request_timeout: int = Field(default=10, description="Таймаут HTTP-запроса, сек")
|
||||||
|
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,
|
||||||
|
description="Не выполнять команды, только показывать что БУДЕТ сделано",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.valves = self.Valves()
|
||||||
|
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"),
|
||||||
|
},
|
||||||
|
"light_status": {
|
||||||
|
"domains": ["light"],
|
||||||
|
"keywords": ["свет", "лампочк", "люстр"],
|
||||||
|
},
|
||||||
|
"climate_status": {
|
||||||
|
"domains": ["climate"],
|
||||||
|
"keywords": ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"],
|
||||||
|
},
|
||||||
|
"battery": {
|
||||||
|
"domains": ["sensor", "binary_sensor"],
|
||||||
|
"keywords": ["батарейк", "аккумулятор", "заряд"],
|
||||||
|
},
|
||||||
|
"media_status": {
|
||||||
|
"domains": ["media_player"],
|
||||||
|
"keywords": ["музык", "медиа", "плеер", "колонк", "телевизор", "тв"],
|
||||||
|
},
|
||||||
|
"presence": {
|
||||||
|
"domains": ["person", "device_tracker"],
|
||||||
|
"keywords": ["кто", "дома", "присутств"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 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
|
||||||
|
kwargs.setdefault("verify", self.valves.verify_ssl)
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
"""Принимает команду на естественном языке и управляет домом / отвечает на вопросы."""
|
||||||
|
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:
|
||||||
|
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