v2.5.1 — фикс фильтра окон/дверей: строго по device_class (door/window/garage_door/lock)
This commit is contained in:
+41
-156
@@ -2,7 +2,7 @@
|
|||||||
title: Home Assistant Smart Control
|
title: Home Assistant Smart Control
|
||||||
description: Управление умным домом через Home Assistant REST API с интерпретацией естественного языка.
|
description: Управление умным домом через Home Assistant REST API с интерпретацией естественного языка.
|
||||||
author: ChatGPT (refactored by MiniMax-M3)
|
author: ChatGPT (refactored by MiniMax-M3)
|
||||||
version: 2.5.0
|
version: 2.5.1
|
||||||
license: MIT
|
license: MIT
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -17,41 +17,20 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class Tools:
|
class Tools:
|
||||||
# ========================================================================
|
|
||||||
# Конфигурация (Valves - задаётся в UI Open WebUI)
|
|
||||||
# ========================================================================
|
|
||||||
class Valves(BaseModel):
|
class Valves(BaseModel):
|
||||||
ha_url: str = Field(
|
ha_url: str = Field(default="https://ha.nasweb.ru", description="URL Home Assistant")
|
||||||
default="https://ha.nasweb.ru",
|
ha_token: str = Field(default="", description="Long-Lived Access Token из профиля HA")
|
||||||
description="URL Home Assistant (например https://ha.nasweb.ru)",
|
verify_ssl: bool = Field(default=False, description="Проверять SSL-сертификат HA")
|
||||||
)
|
|
||||||
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-запроса, сек")
|
request_timeout: int = Field(default=10, description="Таймаут HTTP-запроса, сек")
|
||||||
max_retries: int = Field(default=2, description="Количество ретраев при сетевых сбоях")
|
max_retries: int = Field(default=2, description="Количество ретраев при сетевых сбоях")
|
||||||
cache_ttl: int = Field(default=5, description="TTL кэша состояний HA, сек")
|
cache_ttl: int = Field(default=5, description="TTL кэша состояний HA, сек")
|
||||||
confirm_global_actions: bool = Field(
|
confirm_global_actions: bool = Field(default=True, description="Спрашивать подтверждение для команд на ВСЕ сущности домена")
|
||||||
default=True,
|
dry_run: bool = Field(default=False, description="Не выполнять команды, только показывать что БУДЕТ сделано")
|
||||||
description="Спрашивать подтверждение для команд на ВСЕ сущности домена",
|
|
||||||
)
|
|
||||||
dry_run: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Не выполнять команды, только показывать что БУДЕТ сделано",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.valves = self.Valves()
|
self.valves = self.Valves()
|
||||||
self._states_cache: Optional[Tuple[float, List[Dict[str, Any]]]] = None
|
self._states_cache: Optional[Tuple[float, List[Dict[str, Any]]]] = None
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Стоп-слова и ключевые слова
|
|
||||||
# ========================================================================
|
|
||||||
_STOP_WORDS = {
|
_STOP_WORDS = {
|
||||||
"включи", "выключи", "поставь", "установи", "сделай", "запусти",
|
"включи", "выключи", "поставь", "установи", "сделай", "запусти",
|
||||||
"на", "в", "во", "мне", "пожалуйста", "давай", "ну",
|
"на", "в", "во", "мне", "пожалуйста", "давай", "ну",
|
||||||
@@ -99,9 +78,11 @@ class Tools:
|
|||||||
_MONITOR_DOMAINS = {
|
_MONITOR_DOMAINS = {
|
||||||
"window_door": {
|
"window_door": {
|
||||||
"domains": ["binary_sensor"],
|
"domains": ["binary_sensor"],
|
||||||
"keywords": ["окн", "двер", "форточ", "калитк", "ворота", "гараж"],
|
|
||||||
"open_states": ("open", "on", "unlocked", "detected"),
|
"open_states": ("open", "on", "unlocked", "detected"),
|
||||||
"closed_states": ("closed", "off", "locked", "clear"),
|
"closed_states": ("closed", "off", "locked", "clear"),
|
||||||
|
# Допустимые device_class для окон/дверей/замков.
|
||||||
|
# Если в HA не задан device_class — сущность игнорируется.
|
||||||
|
"valid_device_classes": ("door", "window", "garage_door", "lock"),
|
||||||
},
|
},
|
||||||
"light_status": {
|
"light_status": {
|
||||||
"domains": ["light"],
|
"domains": ["light"],
|
||||||
@@ -136,9 +117,6 @@ class Tools:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# HTTP / API
|
|
||||||
# ========================================================================
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
def _headers(self) -> Dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"Authorization": "Bearer " + self.valves.ha_token,
|
"Authorization": "Bearer " + self.valves.ha_token,
|
||||||
@@ -146,7 +124,6 @@ class Tools:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
||||||
"""HTTP-запрос с ретраями и логированием."""
|
|
||||||
url = self.valves.ha_url + path
|
url = self.valves.ha_url + path
|
||||||
last_exc: Optional[Exception] = None
|
last_exc: Optional[Exception] = None
|
||||||
kwargs.setdefault("verify", self.valves.verify_ssl)
|
kwargs.setdefault("verify", self.valves.verify_ssl)
|
||||||
@@ -171,7 +148,6 @@ class Tools:
|
|||||||
raise RuntimeError("Home Assistant недоступен: " + str(last_exc))
|
raise RuntimeError("Home Assistant недоступен: " + str(last_exc))
|
||||||
|
|
||||||
def _get_states(self, force: bool = False) -> List[Dict[str, Any]]:
|
def _get_states(self, force: bool = False) -> List[Dict[str, Any]]:
|
||||||
"""Получить состояния HA с TTL-кэшем."""
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if (
|
if (
|
||||||
not force
|
not force
|
||||||
@@ -186,7 +162,6 @@ class Tools:
|
|||||||
return states
|
return states
|
||||||
|
|
||||||
def _call_service(self, domain: str, service: str, data: Dict[str, Any]) -> bool:
|
def _call_service(self, domain: str, service: str, data: Dict[str, Any]) -> bool:
|
||||||
"""Вызов сервиса HA. Возвращает True если выполнено, False если dry-run."""
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"%s -> %s.%s %s",
|
"%s -> %s.%s %s",
|
||||||
"[DRY] " if self.valves.dry_run else "",
|
"[DRY] " if self.valves.dry_run else "",
|
||||||
@@ -197,17 +172,12 @@ class Tools:
|
|||||||
self._request("POST", "/api/services/" + domain + "/" + service, json=data)
|
self._request("POST", "/api/services/" + domain + "/" + service, json=data)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Поиск сущностей
|
|
||||||
# ========================================================================
|
|
||||||
def _tokenize(self, text: str) -> List[str]:
|
def _tokenize(self, text: str) -> List[str]:
|
||||||
"""Токенизация: фильтр стоп-слов, приведение к нижнему регистру."""
|
|
||||||
text = text.lower().strip().replace("ё", "е")
|
text = text.lower().strip().replace("ё", "е")
|
||||||
tokens = re.findall(r"[а-яa-z0-9]+", text)
|
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]
|
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:
|
def _entity_text(self, s: Dict[str, Any]) -> str:
|
||||||
"""Полный текст сущности: id + friendly_name + area."""
|
|
||||||
attrs = s.get("attributes", {}) or {}
|
attrs = s.get("attributes", {}) or {}
|
||||||
return " ".join([
|
return " ".join([
|
||||||
s.get("entity_id", ""),
|
s.get("entity_id", ""),
|
||||||
@@ -222,10 +192,8 @@ class Tools:
|
|||||||
domain: Optional[str] = None,
|
domain: Optional[str] = None,
|
||||||
require_all: bool = False,
|
require_all: bool = False,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Поиск сущностей по ключевым словам."""
|
|
||||||
states = self._get_states()
|
states = self._get_states()
|
||||||
result: List[str] = []
|
result: List[str] = []
|
||||||
|
|
||||||
for s in states:
|
for s in states:
|
||||||
eid = s["entity_id"]
|
eid = s["entity_id"]
|
||||||
if domain and not eid.startswith(domain + "."):
|
if domain and not eid.startswith(domain + "."):
|
||||||
@@ -238,22 +206,18 @@ class Tools:
|
|||||||
matches = sum(1 for kw in keywords if kw in haystack)
|
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):
|
if (require_all and matches == len(keywords)) or (not require_all and matches > 0):
|
||||||
result.append(eid)
|
result.append(eid)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_state(self, entity_id: str) -> Optional[str]:
|
def _get_state(self, entity_id: str) -> Optional[str]:
|
||||||
"""Получить текущее состояние конкретной сущности."""
|
|
||||||
for s in self._get_states():
|
for s in self._get_states():
|
||||||
if s["entity_id"] == entity_id:
|
if s["entity_id"] == entity_id:
|
||||||
return s.get("state")
|
return s.get("state")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _attr(self, s: Dict[str, Any], key: str, default=None) -> Any:
|
def _attr(self, s: Dict[str, Any], key: str, default=None) -> Any:
|
||||||
"""Безопасное извлечение атрибута."""
|
|
||||||
return (s.get("attributes", {}) or {}).get(key, default)
|
return (s.get("attributes", {}) or {}).get(key, default)
|
||||||
|
|
||||||
def _resolve_domain(self, cmd: str) -> Optional[str]:
|
def _resolve_domain(self, cmd: str) -> Optional[str]:
|
||||||
"""Определить домен HA по русской команде."""
|
|
||||||
for domain, aliases in self._DOMAIN_ALIASES.items():
|
for domain, aliases in self._DOMAIN_ALIASES.items():
|
||||||
for a in aliases:
|
for a in aliases:
|
||||||
if a in cmd:
|
if a in cmd:
|
||||||
@@ -261,7 +225,6 @@ class Tools:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _find_entity_by_name(self, name: str, domain: Optional[str] = None) -> Optional[str]:
|
def _find_entity_by_name(self, name: str, domain: Optional[str] = None) -> Optional[str]:
|
||||||
"""Найти первый entity_id, у которого friendly_name или id совпадает с name."""
|
|
||||||
states = self._get_states(force=True)
|
states = self._get_states(force=True)
|
||||||
name_lower = name.lower().strip()
|
name_lower = name.lower().strip()
|
||||||
for s in states:
|
for s in states:
|
||||||
@@ -271,16 +234,11 @@ class Tools:
|
|||||||
fn = (self._attr(s, "friendly_name", "") or "").lower()
|
fn = (self._attr(s, "friendly_name", "") or "").lower()
|
||||||
if fn == name_lower:
|
if fn == name_lower:
|
||||||
return eid
|
return eid
|
||||||
# Частичное совпадение
|
|
||||||
if name_lower in fn or name_lower in eid.lower():
|
if name_lower in fn or name_lower in eid.lower():
|
||||||
return eid
|
return eid
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Парсинг чисел
|
|
||||||
# ========================================================================
|
|
||||||
def _extract_number(self, text: str, default: Optional[float] = None) -> Optional[float]:
|
def _extract_number(self, text: str, default: Optional[float] = None) -> Optional[float]:
|
||||||
"""Извлечь число из текста: арабские цифры или русские слова."""
|
|
||||||
m = re.search(r"-?\d+(?:[.,]\d+)?", text)
|
m = re.search(r"-?\d+(?:[.,]\d+)?", text)
|
||||||
if m:
|
if m:
|
||||||
return float(m.group(0).replace(",", "."))
|
return float(m.group(0).replace(",", "."))
|
||||||
@@ -290,11 +248,7 @@ class Tools:
|
|||||||
return float(self._RU_NUMS[word])
|
return float(self._RU_NUMS[word])
|
||||||
return default
|
return default
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Форматирование ответа
|
|
||||||
# ========================================================================
|
|
||||||
def _format_entity(self, s: Dict[str, Any]) -> str:
|
def _format_entity(self, s: Dict[str, Any]) -> str:
|
||||||
"""Красиво отформатировать одну сущность для вывода."""
|
|
||||||
attrs = s.get("attributes", {}) or {}
|
attrs = s.get("attributes", {}) or {}
|
||||||
fn = attrs.get("friendly_name", s["entity_id"])
|
fn = attrs.get("friendly_name", s["entity_id"])
|
||||||
state = s.get("state", "?")
|
state = s.get("state", "?")
|
||||||
@@ -307,17 +261,24 @@ class Tools:
|
|||||||
# Мониторинг
|
# Мониторинг
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
def _monitor_windows_doors(self, tokens: List[str]) -> List[str]:
|
def _monitor_windows_doors(self, tokens: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Какие окна/двери открыты.
|
||||||
|
|
||||||
|
Строгий фильтр: только binary_sensor с device_class в
|
||||||
|
(door, window, garage_door, lock). Это исключает ложные
|
||||||
|
срабатывания на датчики движения, батареи штор, шторы Aqara и т.п.
|
||||||
|
"""
|
||||||
cfg = self._MONITOR_DOMAINS["window_door"]
|
cfg = self._MONITOR_DOMAINS["window_door"]
|
||||||
|
valid_dc = cfg["valid_device_classes"]
|
||||||
states = self._get_states()
|
states = self._get_states()
|
||||||
|
|
||||||
items: List[Tuple[Dict[str, Any], bool, bool]] = []
|
items: List[Tuple[Dict[str, Any], bool, bool]] = []
|
||||||
for s in states:
|
for s in states:
|
||||||
eid = s["entity_id"]
|
eid = s["entity_id"]
|
||||||
is_relevant_domain = eid.startswith("binary_sensor.")
|
if not eid.startswith("binary_sensor."):
|
||||||
|
continue
|
||||||
device_class = self._attr(s, "device_class", "")
|
device_class = self._attr(s, "device_class", "")
|
||||||
is_door_window = device_class in ("door", "window", "garage_door", "lock")
|
if device_class not in valid_dc:
|
||||||
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
|
continue
|
||||||
cur = s.get("state", "")
|
cur = s.get("state", "")
|
||||||
is_open = cur in cfg["open_states"]
|
is_open = cur in cfg["open_states"]
|
||||||
@@ -325,7 +286,7 @@ class Tools:
|
|||||||
items.append((s, is_open, is_closed))
|
items.append((s, is_open, is_closed))
|
||||||
|
|
||||||
if not items:
|
if not items:
|
||||||
return ["датчики окон/дверей не найдены"]
|
return ["датчики окон/дверей не найдены (проверьте device_class в HA)"]
|
||||||
|
|
||||||
open_items = [s for s, is_open, _ in items if is_open]
|
open_items = [s for s, is_open, _ in items if is_open]
|
||||||
closed_items = [s for s, _, is_closed in items if is_closed]
|
closed_items = [s for s, _, is_closed in items if is_closed]
|
||||||
@@ -340,6 +301,7 @@ class Tools:
|
|||||||
lines.append("Закрыто: " + str(len(closed_items)) + " шт.")
|
lines.append("Закрыто: " + str(len(closed_items)) + " шт.")
|
||||||
if unknown:
|
if unknown:
|
||||||
lines.append("Неизвестно: " + str(len(unknown)) + " шт.")
|
lines.append("Неизвестно: " + str(len(unknown)) + " шт.")
|
||||||
|
|
||||||
if tokens:
|
if tokens:
|
||||||
filtered = []
|
filtered = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
@@ -347,6 +309,7 @@ class Tools:
|
|||||||
filtered.append(line)
|
filtered.append(line)
|
||||||
if filtered:
|
if filtered:
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
def _monitor_lights(self, tokens: List[str]) -> List[str]:
|
def _monitor_lights(self, tokens: List[str]) -> List[str]:
|
||||||
@@ -549,7 +512,6 @@ class Tools:
|
|||||||
# Главная точка входа
|
# Главная точка входа
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
def smart(self, command: str) -> str:
|
def smart(self, command: str) -> str:
|
||||||
"""Принимает команду на естественном языке и управляет домом / отвечает на вопросы."""
|
|
||||||
if not self.valves.ha_token:
|
if not self.valves.ha_token:
|
||||||
return (
|
return (
|
||||||
"Не задан HA Token. Укажите его в настройках инструмента (Valves) "
|
"Не задан HA Token. Укажите его в настройках инструмента (Valves) "
|
||||||
@@ -562,7 +524,6 @@ class Tools:
|
|||||||
warnings: List[str] = []
|
warnings: List[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Маршрутизация 'покажи все / список устройств' -> list_devices
|
|
||||||
if any(w in cmd for w in [
|
if any(w in cmd for w in [
|
||||||
"покажи", "список устройств", "какие устройств",
|
"покажи", "список устройств", "какие устройств",
|
||||||
"что есть", "инвентар", "все устройств",
|
"что есть", "инвентар", "все устройств",
|
||||||
@@ -570,14 +531,10 @@ class Tools:
|
|||||||
target = self._resolve_domain(cmd)
|
target = self._resolve_domain(cmd)
|
||||||
return self.list_devices(domain=target or "", limit=80)
|
return self.list_devices(domain=target or "", limit=80)
|
||||||
|
|
||||||
# Общий отчёт
|
|
||||||
if "отчет" in cmd or "отчёт" in cmd or "обзор" in cmd or "все вместе" in cmd:
|
if "отчет" in cmd or "отчёт" in cmd or "обзор" in cmd or "все вместе" in cmd:
|
||||||
return "\n".join(self._monitor_full_report())
|
return "\n".join(self._monitor_full_report())
|
||||||
|
|
||||||
# Переименование
|
|
||||||
if "переимен" in cmd:
|
if "переимен" in cmd:
|
||||||
# Ожидаемый формат: "переименуй <entity_id или имя> в <новое имя>"
|
|
||||||
# или: "переименуй <entity_id> как <новое имя>"
|
|
||||||
m = re.search(r"переимен\w*\s+(.+?)\s+(?:в|как|на)\s+(.+)$", cmd)
|
m = re.search(r"переимен\w*\s+(.+?)\s+(?:в|как|на)\s+(.+)$", cmd)
|
||||||
if m:
|
if m:
|
||||||
target = m.group(1).strip().strip('"').strip("'")
|
target = m.group(1).strip().strip('"').strip("'")
|
||||||
@@ -592,7 +549,6 @@ class Tools:
|
|||||||
"Пример: `переименуй automation.сертификат в Обновление SSL`"
|
"Пример: `переименуй automation.сертификат в Обновление SSL`"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Включение/выключение автоматизации
|
|
||||||
if "автоматизац" in cmd and any(w in cmd for w in ("включ", "выключ", "активир", "деактивир")):
|
if "автоматизац" in cmd and any(w in cmd for w in ("включ", "выключ", "активир", "деактивир")):
|
||||||
target = ""
|
target = ""
|
||||||
for s in self._get_states(force=True):
|
for s in self._get_states(force=True):
|
||||||
@@ -694,7 +650,6 @@ class Tools:
|
|||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
actions: List[str] = []
|
actions: List[str] = []
|
||||||
|
|
||||||
# Свет
|
|
||||||
if "свет" in cmd or "лампочк" in cmd:
|
if "свет" in cmd or "лампочк" in cmd:
|
||||||
entities = self._find_entities(tokens, "light")
|
entities = self._find_entities(tokens, "light")
|
||||||
if not entities:
|
if not entities:
|
||||||
@@ -742,7 +697,6 @@ class Tools:
|
|||||||
label = "выключен" if turn_off else "включён"
|
label = "выключен" if turn_off else "включён"
|
||||||
actions.append("свет " + label + " (" + str(count) + " шт.)")
|
actions.append("свет " + label + " (" + str(count) + " шт.)")
|
||||||
|
|
||||||
# Розетки / реле / выключатели
|
|
||||||
if any(k in cmd for k in ["розетк", "выключател", "устройств", "реле"]):
|
if any(k in cmd for k in ["розетк", "выключател", "устройств", "реле"]):
|
||||||
entities = self._find_entities(tokens, "switch")
|
entities = self._find_entities(tokens, "switch")
|
||||||
if not entities:
|
if not entities:
|
||||||
@@ -766,7 +720,6 @@ class Tools:
|
|||||||
label = "выключены" if turn_off else "включены"
|
label = "выключены" if turn_off else "включены"
|
||||||
actions.append("розетки " + label + " (" + str(count) + " шт.)")
|
actions.append("розетки " + label + " (" + str(count) + " шт.)")
|
||||||
|
|
||||||
# Климат
|
|
||||||
if any(k in cmd for k in ["температур", "градус", "тепл", "холод", "климат"]):
|
if any(k in cmd for k in ["температур", "градус", "тепл", "холод", "климат"]):
|
||||||
temp = self._extract_number(cmd, default=22)
|
temp = self._extract_number(cmd, default=22)
|
||||||
entities = self._find_entities(tokens, "climate")
|
entities = self._find_entities(tokens, "climate")
|
||||||
@@ -801,7 +754,6 @@ class Tools:
|
|||||||
)
|
)
|
||||||
actions.append("режим охлаждения")
|
actions.append("режим охлаждения")
|
||||||
|
|
||||||
# Медиа
|
|
||||||
if any(k in cmd for k in ["музык", "медиа", "плеер", "колонк"]):
|
if any(k in cmd for k in ["музык", "медиа", "плеер", "колонк"]):
|
||||||
entities = self._find_entities(tokens, "media_player")
|
entities = self._find_entities(tokens, "media_player")
|
||||||
if not entities:
|
if not entities:
|
||||||
@@ -845,7 +797,6 @@ class Tools:
|
|||||||
if count:
|
if count:
|
||||||
actions.append("воспроизведение (" + str(count) + " шт.)")
|
actions.append("воспроизведение (" + str(count) + " шт.)")
|
||||||
|
|
||||||
# Шторы
|
|
||||||
if any(k in cmd for k in ["штор", "жалюз", "роллет", "гардин"]):
|
if any(k in cmd for k in ["штор", "жалюз", "роллет", "гардин"]):
|
||||||
entities = self._find_entities(tokens, "cover")
|
entities = self._find_entities(tokens, "cover")
|
||||||
if not entities:
|
if not entities:
|
||||||
@@ -863,7 +814,6 @@ class Tools:
|
|||||||
label = "закрыты" if is_close else "открыты"
|
label = "закрыты" if is_close else "открыты"
|
||||||
actions.append("шторы " + label + " (" + str(count) + " шт.)")
|
actions.append("шторы " + label + " (" + str(count) + " шт.)")
|
||||||
|
|
||||||
# Замки
|
|
||||||
if any(k in cmd for k in ["замок", "заблокир", "закрыт"]):
|
if any(k in cmd for k in ["замок", "заблокир", "закрыт"]):
|
||||||
entities = self._find_entities(tokens, "lock")
|
entities = self._find_entities(tokens, "lock")
|
||||||
if entities:
|
if entities:
|
||||||
@@ -878,7 +828,6 @@ class Tools:
|
|||||||
"замки " + ("закрыты" if is_lock else "открыты") + " (" + str(len(entities)) + " шт.)"
|
"замки " + ("закрыты" if is_lock else "открыты") + " (" + str(len(entities)) + " шт.)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Вентиляторы
|
|
||||||
if "вентилятор" in cmd or "кулер" in cmd:
|
if "вентилятор" in cmd or "кулер" in cmd:
|
||||||
entities = self._find_entities(tokens, "fan")
|
entities = self._find_entities(tokens, "fan")
|
||||||
if entities:
|
if entities:
|
||||||
@@ -893,7 +842,6 @@ class Tools:
|
|||||||
"вентиляторы " + ("выключены" if is_off else "включены") + " (" + str(len(entities)) + " шт.)"
|
"вентиляторы " + ("выключены" if is_off else "включены") + " (" + str(len(entities)) + " шт.)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Пылесосы
|
|
||||||
if "пылесос" in cmd or "робот" in cmd:
|
if "пылесос" in cmd or "робот" in cmd:
|
||||||
entities = self._find_entities(tokens, "vacuum")
|
entities = self._find_entities(tokens, "vacuum")
|
||||||
if entities:
|
if entities:
|
||||||
@@ -910,7 +858,6 @@ class Tools:
|
|||||||
self._call_service("vacuum", "start", {"entity_id": e})
|
self._call_service("vacuum", "start", {"entity_id": e})
|
||||||
actions.append("пылесос уборка")
|
actions.append("пылесос уборка")
|
||||||
|
|
||||||
# Кнопки
|
|
||||||
if "кнопк" in cmd and any(w in cmd for w in ("нажм", "кликн", "тапн", "триггерн", "активир")):
|
if "кнопк" in cmd and any(w in cmd for w in ("нажм", "кликн", "тапн", "триггерн", "активир")):
|
||||||
entities = self._find_entities(tokens, "button")
|
entities = self._find_entities(tokens, "button")
|
||||||
if entities:
|
if entities:
|
||||||
@@ -922,7 +869,6 @@ class Tools:
|
|||||||
else:
|
else:
|
||||||
warnings.append("кнопки не найдены")
|
warnings.append("кнопки не найдены")
|
||||||
|
|
||||||
# Сцены / скрипты
|
|
||||||
if any(k in cmd for k in ["сцен", "режим", "запуст"]):
|
if any(k in cmd for k in ["сцен", "режим", "запуст"]):
|
||||||
all_scripts = self._find_entities([], "script")
|
all_scripts = self._find_entities([], "script")
|
||||||
matched: List[str] = []
|
matched: List[str] = []
|
||||||
@@ -942,7 +888,7 @@ class Tools:
|
|||||||
return actions
|
return actions
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Диагностика / справочник устройств (отдельный инструмент)
|
# Диагностика / справочник устройств
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
def list_devices(
|
def list_devices(
|
||||||
self,
|
self,
|
||||||
@@ -952,30 +898,8 @@ class Tools:
|
|||||||
show_unavailable: bool = True,
|
show_unavailable: bool = True,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
|
||||||
Получить список устройств из Home Assistant с фильтрами.
|
|
||||||
|
|
||||||
Используется для инвентаризации: какие устройства есть, в каком состоянии,
|
|
||||||
какие из них offline. Полезно для отладки и проверки интеграций.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
domain: фильтр по домену (light, switch, binary_sensor, sensor, climate, ...).
|
|
||||||
Спецзначение "window_door" — окна и двери.
|
|
||||||
Пусто — все домены.
|
|
||||||
device_class: фильтр по device_class атрибуту
|
|
||||||
(door, window, motion, battery, temperature, ...).
|
|
||||||
Пусто — все классы.
|
|
||||||
only_with_state: оставить только с указанным состоянием (on/off/open/closed/...).
|
|
||||||
Пусто — любые.
|
|
||||||
show_unavailable: показывать ли устройства со state="unavailable"/"unknown".
|
|
||||||
limit: максимум устройств в ответе.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Список устройств в формате markdown, сгруппированный по доменам.
|
|
||||||
"""
|
|
||||||
if not self.valves.ha_token:
|
if not self.valves.ha_token:
|
||||||
return "Не задан HA Token (Valves)."
|
return "Не задан HA Token (Valves)."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
states = self._get_states(force=True)
|
states = self._get_states(force=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -994,9 +918,10 @@ class Tools:
|
|||||||
|
|
||||||
if domain:
|
if domain:
|
||||||
if domain == "window_door":
|
if domain == "window_door":
|
||||||
if dc not in ("door", "window", "garage_door", "lock") and \
|
# Строгий фильтр: только binary_sensor с правильным device_class
|
||||||
not any(k in (eid + " " + (attrs.get("friendly_name", "") or "").lower())
|
if not eid.startswith("binary_sensor."):
|
||||||
for k in ["окн", "двер", "форточ", "балкон", "калитк", "ворота", "гараж"]):
|
continue
|
||||||
|
if dc not in ("door", "window", "garage_door", "lock"):
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
if not eid.startswith(domain + "."):
|
if not eid.startswith(domain + "."):
|
||||||
@@ -1038,7 +963,7 @@ class Tools:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# 🏷 Переименование сущностей
|
# Переименование
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
def rename_entity(
|
def rename_entity(
|
||||||
self,
|
self,
|
||||||
@@ -1046,21 +971,6 @@ class Tools:
|
|||||||
name: str = "",
|
name: str = "",
|
||||||
friendly_name: str = "",
|
friendly_name: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
|
||||||
Переименовать сущность в Home Assistant (меняет friendly_name).
|
|
||||||
Работает для automation, script, light, switch, scene и др.
|
|
||||||
|
|
||||||
Можно указать либо entity_id (точное имя), либо friendly_name (для поиска).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entity_id: точный id сущности (например "automation.сертификат").
|
|
||||||
Если пусто — ищем по friendly_name.
|
|
||||||
name: новое имя.
|
|
||||||
friendly_name: текущее friendly_name для поиска (если entity_id не указан).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Подтверждение или сообщение об ошибке.
|
|
||||||
"""
|
|
||||||
if not self.valves.ha_token:
|
if not self.valves.ha_token:
|
||||||
return "Не задан HA Token (Valves)."
|
return "Не задан HA Token (Valves)."
|
||||||
if not name:
|
if not name:
|
||||||
@@ -1068,14 +978,13 @@ class Tools:
|
|||||||
|
|
||||||
target = entity_id
|
target = entity_id
|
||||||
if not target or "." not in target:
|
if not target or "." not in target:
|
||||||
# Поиск по friendly_name
|
|
||||||
search_name = friendly_name or entity_id
|
search_name = friendly_name or entity_id
|
||||||
target = self._find_entity_by_name(search_name)
|
target = self._find_entity_by_name(search_name)
|
||||||
if not target:
|
if not target:
|
||||||
return "Не нашёл сущность с именем: " + search_name
|
return "Не нашёл сущность с именем: " + search_name
|
||||||
|
|
||||||
if self.valves.dry_run:
|
if self.valves.dry_run:
|
||||||
return f"[DRY-RUN] Переименовал бы {target} -> {name}"
|
return "[DRY-RUN] Переименовал бы " + target + " -> " + name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._request(
|
self._request(
|
||||||
@@ -1083,13 +992,13 @@ class Tools:
|
|||||||
"/api/services/homeassistant/update_entity",
|
"/api/services/homeassistant/update_entity",
|
||||||
json={"entity_id": target, "name": name},
|
json={"entity_id": target, "name": name},
|
||||||
)
|
)
|
||||||
self._get_states(force=True) # сброс кэша
|
self._get_states(force=True)
|
||||||
return f"Переименовано: {target} -> '{name}'"
|
return "Переименовано: " + target + " -> '" + name + "'"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Ошибка переименования {target}: {e}"
|
return "Ошибка переименования " + target + ": " + str(e)
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# ⚙️ Управление автоматизациями
|
# Управление автоматизациями
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
def toggle_automation(
|
def toggle_automation(
|
||||||
self,
|
self,
|
||||||
@@ -1097,18 +1006,6 @@ class Tools:
|
|||||||
friendly_name: str = "",
|
friendly_name: str = "",
|
||||||
turn_off: bool = False,
|
turn_off: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
|
||||||
Включить или выключить автоматизацию.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entity_id: точный id (например "automation.сертификат").
|
|
||||||
Если пусто — ищем по friendly_name.
|
|
||||||
friendly_name: имя для поиска.
|
|
||||||
turn_off: True — выключить, False — включить.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Подтверждение или сообщение об ошибке.
|
|
||||||
"""
|
|
||||||
if not self.valves.ha_token:
|
if not self.valves.ha_token:
|
||||||
return "Не задан HA Token (Valves)."
|
return "Не задан HA Token (Valves)."
|
||||||
|
|
||||||
@@ -1123,7 +1020,7 @@ class Tools:
|
|||||||
action_label = "выключена" if turn_off else "включена"
|
action_label = "выключена" if turn_off else "включена"
|
||||||
|
|
||||||
if self.valves.dry_run:
|
if self.valves.dry_run:
|
||||||
return f"[DRY-RUN] Автоматизация {target} была бы {action_label}"
|
return "[DRY-RUN] Автоматизация " + target + " была бы " + action_label
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._request(
|
self._request(
|
||||||
@@ -1132,9 +1029,9 @@ class Tools:
|
|||||||
json={"entity_id": target},
|
json={"entity_id": target},
|
||||||
)
|
)
|
||||||
self._get_states(force=True)
|
self._get_states(force=True)
|
||||||
return f"Автоматизация {target} {action_label}"
|
return "Автоматизация " + target + " " + action_label
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Ошибка: {e}"
|
return "Ошибка: " + str(e)
|
||||||
|
|
||||||
def trigger_automation(
|
def trigger_automation(
|
||||||
self,
|
self,
|
||||||
@@ -1142,18 +1039,6 @@ class Tools:
|
|||||||
friendly_name: str = "",
|
friendly_name: str = "",
|
||||||
skip_condition: bool = True,
|
skip_condition: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
|
||||||
Запустить автоматизацию вручную (триггернуть).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entity_id: точный id (например "automation.сертификат").
|
|
||||||
Если пусто — ищем по friendly_name.
|
|
||||||
friendly_name: имя для поиска.
|
|
||||||
skip_condition: пропустить ли условия (по умолчанию True).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Подтверждение или сообщение об ошибке.
|
|
||||||
"""
|
|
||||||
if not self.valves.ha_token:
|
if not self.valves.ha_token:
|
||||||
return "Не задан HA Token (Valves)."
|
return "Не задан HA Token (Valves)."
|
||||||
|
|
||||||
@@ -1165,7 +1050,7 @@ class Tools:
|
|||||||
return "Не нашёл автоматизацию: " + search_name
|
return "Не нашёл автоматизацию: " + search_name
|
||||||
|
|
||||||
if self.valves.dry_run:
|
if self.valves.dry_run:
|
||||||
return f"[DRY-RUN] Триггернул бы {target}"
|
return "[DRY-RUN] Триггернул бы " + target
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data: Dict[str, Any] = {"entity_id": target}
|
data: Dict[str, Any] = {"entity_id": target}
|
||||||
@@ -1176,6 +1061,6 @@ class Tools:
|
|||||||
"/api/services/automation/trigger",
|
"/api/services/automation/trigger",
|
||||||
json=data,
|
json=data,
|
||||||
)
|
)
|
||||||
return f"Триггер запущен: {target}"
|
return "Триггер запущен: " + target
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Ошибка: {e}"
|
return "Ошибка: " + str(e)
|
||||||
Reference in New Issue
Block a user