v2.4.0 — мониторинг воздуха/движения, фикс батареек, маршрутизация 'покажи', кнопки, общий отчёт

This commit is contained in:
2026-07-29 19:51:54 +03:00
parent 957c64d795
commit dc349d0c90
+223 -44
View File
@@ -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.3.0 version: 2.4.0
license: MIT license: MIT
""" """
@@ -62,6 +62,28 @@ class Tools:
"комнате", "квартире", "доме", "гостиной", "спальне", "кухне", "комнате", "квартире", "доме", "гостиной", "спальне", "кухне",
"детской", "ванной", "прихожей", "коридоре", "кабинете", "детской", "ванной", "прихожей", "коридоре", "кабинете",
"и", "а", "но", "или", "же", "бы", "ли", "и", "а", "но", "или", "же", "бы", "ли",
"покажи", "список", "какие", "есть", "устройств", "устройства",
"устройство", "все", "весь", "вся",
}
# Домены и их алиасы (русский -> домен HA)
_DOMAIN_ALIASES = {
"binary_sensor": ["binary_sensor", "бинарн", "датчик", "датчики", "сенсор", "сенсоры"],
"sensor": ["sensor", "показател", "значение", "значения"],
"light": ["light", "свет", "лампочк", "лампа", "люстр", "свитч"],
"switch": ["switch", "розетк", "выключател", "реле"],
"climate": ["climate", "климат", "кондиционер", "термостат"],
"cover": ["cover", "штор", "жалюз", "роллет", "гардин"],
"media_player": ["media_player", "медиа", "плеер", "колонк", "телевизор"],
"lock": ["lock", "замок"],
"fan": ["fan", "вентилятор", "кулер"],
"vacuum": ["vacuum", "пылесос", "робот"],
"person": ["person", "люди", "пользовател"],
"device_tracker": ["device_tracker", "трекер", "геолокац"],
"button": ["button", "кнопк"],
"automation": ["automation", "автоматизац"],
"script": ["script", "скрипт", "сцен", "сценарий"],
"input_boolean": ["input_boolean", "переключател", "тумблер"],
} }
_RU_NUMS = { _RU_NUMS = {
@@ -102,6 +124,17 @@ class Tools:
"domains": ["person", "device_tracker"], "domains": ["person", "device_tracker"],
"keywords": ["кто", "дома", "присутств"], "keywords": ["кто", "дома", "присутств"],
}, },
"motion": {
"domains": ["binary_sensor"],
"keywords": ["движен", "двигать", "присутств"],
"device_classes": ["motion", "occupancy"],
},
"air_quality": {
"domains": ["sensor"],
"keywords": ["воздух", "tvoc", "co2", "pm2", "pm10", "влажн", "увлажн"],
"device_classes": ["volatile_organic_compounds", "carbon_dioxide",
"pm25", "pm10", "humidity", "aqi"],
},
} }
# ======================================================================== # ========================================================================
@@ -220,6 +253,14 @@ class Tools:
"""Безопасное извлечение атрибута.""" """Безопасное извлечение атрибута."""
return (s.get("attributes", {}) or {}).get(key, default) return (s.get("attributes", {}) or {}).get(key, default)
def _resolve_domain(self, cmd: str) -> Optional[str]:
"""Определить домен HA по русской команде."""
for domain, aliases in self._DOMAIN_ALIASES.items():
for a in aliases:
if a in cmd:
return domain
return None
# ======================================================================== # ========================================================================
# Парсинг чисел # Парсинг чисел
# ======================================================================== # ========================================================================
@@ -330,62 +371,137 @@ class Tools:
return lines return lines
def _monitor_climate(self, tokens: List[str]) -> List[str]: def _monitor_climate(self, tokens: List[str]) -> List[str]:
"""Какая температура и состояние климата.""" """Какая температура и состояние климата.
cfg = self._MONITOR_DOMAINS["climate_status"] Берём ТОЛЬКО climate.* или sensor с device_class=temperature/humidity.
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() states = self._get_states()
low_battery: List[Tuple[Dict[str, Any], float]] = [] items: List[Dict[str, Any]] = []
for s in states: for s in states:
eid = s["entity_id"] eid = s["entity_id"]
device_class = self._attr(s, "device_class", "") dc = self._attr(s, "device_class", "")
unit = self._attr(s, "unit_of_measurement", "")
is_battery = ( # Только climate или sensor с релевантным device_class
device_class == "battery" if eid.startswith("climate."):
or "battery" in eid items.append(s)
or unit == "%" elif eid.startswith("sensor.") and dc in (
) and ( "temperature", "humidity", "current_temperature",
"battery" in eid ):
or device_class == "battery" items.append(s)
or "батарейк" in self._entity_text(s)
)
if not is_battery: # Фильтр по комнате/токенам
if tokens:
items = [
s for s in items
if any(t in self._entity_text(s) for t in tokens)
]
if not items:
return ["климат-устройства не найдены"]
lines = ["**Климат:**"]
for s in items[:20]:
lines.append(self._format_entity(s))
return lines
def _monitor_battery(self, tokens: List[str]) -> List[str]:
"""Какие батарейки садятся.
Учитываем:
- sensor.* с device_class=battery и state как число
- binary_sensor.* с device_class=battery (on = садится)
"""
states = self._get_states()
low_battery: List[Tuple[Dict[str, Any], float]] = []
binary_low: List[Dict[str, Any]] = []
for s in states:
eid = s["entity_id"]
dc = self._attr(s, "device_class", "")
cur = s.get("state", "")
# Числовой сенсор батареи
if eid.startswith("sensor.") and dc == "battery":
if cur in ("unavailable", "unknown", "none", ""):
continue continue
try: try:
level = float(s.get("state", "")) level = float(cur)
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
if level < 30: if level < 30:
low_battery.append((s, level)) low_battery.append((s, level))
continue
if not low_battery: # Бинарный сенсор "батарея разряжена"
if eid.startswith("binary_sensor.") and dc == "battery":
if cur == "on":
binary_low.append(s)
continue
if not low_battery and not binary_low:
return ["Все батарейки в норме (>=30%)"] return ["Все батарейки в норме (>=30%)"]
low_battery.sort(key=lambda x: x[1])
lines = ["**Низкий заряд:**"] lines = ["**Низкий заряд:**"]
# Сортируем по возрастанию
low_battery.sort(key=lambda x: x[1])
for s, level in low_battery[:15]: for s, level in low_battery[:15]:
lines.append(self._format_entity(s)) lines.append(self._format_entity(s))
for s in binary_low[:10]:
lines.append(" ! " + self._attr(s, "friendly_name", s["entity_id"]) + ": разряжена")
return lines
def _monitor_motion(self, tokens: List[str]) -> List[str]:
"""Где есть движение прямо сейчас."""
states = self._get_states()
active: List[Dict[str, Any]] = []
for s in states:
eid = s["entity_id"]
if not eid.startswith("binary_sensor."):
continue
dc = self._attr(s, "device_class", "")
if dc not in ("motion", "occupancy"):
continue
if s.get("state") == "on":
active.append(s)
if not active:
return ["Нигде нет движения (все датчики молчат)"]
if tokens:
active = [s for s in active if any(t in self._entity_text(s) for t in tokens)]
lines = ["**Движение обнаружено:**"]
for s in active[:15]:
lines.append(self._format_entity(s))
return lines
def _monitor_air_quality(self, tokens: List[str]) -> List[str]:
"""Качество воздуха: TVOC, CO2, PM2.5/10, влажность."""
states = self._get_states()
items: List[Dict[str, Any]] = []
target_dc = {
"volatile_organic_compounds", "carbon_dioxide",
"pm25", "pm10", "humidity", "aqi",
}
for s in states:
eid = s["entity_id"]
if not eid.startswith("sensor."):
continue
dc = self._attr(s, "device_class", "")
name = (self._attr(s, "friendly_name", "") or "").lower()
if dc in target_dc or any(k in name for k in ["tvoc", "co2", "влажн", "воздух", "pm2", "pm10"]):
items.append(s)
if not items:
return ["датчики качества воздуха не найдены"]
if tokens:
items = [s for s in items if any(t in self._entity_text(s) for t in tokens)]
lines = ["**Качество воздуха:**"]
for s in items[:15]:
lines.append(self._format_entity(s))
return lines return lines
def _monitor_media(self, tokens: List[str]) -> List[str]: def _monitor_media(self, tokens: List[str]) -> List[str]:
@@ -448,6 +564,24 @@ class Tools:
lines.append(" (информация отсутствует)") lines.append(" (информация отсутствует)")
return lines return lines
def _monitor_full_report(self) -> List[str]:
"""Общий отчёт по дому одной фразой."""
lines = ["**Отчёт по дому:**", ""]
lines.extend(self._monitor_presence([]))
lines.append("")
lines.extend(self._monitor_windows_doors([]))
lines.append("")
lines.extend(self._monitor_lights([]))
lines.append("")
lines.extend(self._monitor_climate([]))
lines.append("")
lines.extend(self._monitor_air_quality([]))
lines.append("")
lines.extend(self._monitor_motion([]))
lines.append("")
lines.extend(self._monitor_battery([]))
return lines
# ======================================================================== # ========================================================================
# Главная точка входа # Главная точка входа
# ======================================================================== # ========================================================================
@@ -465,6 +599,25 @@ class Tools:
warnings: List[str] = [] warnings: List[str] = []
try: try:
# ============================================================
# Маршрутизация 'покажи все / список устройств' -> list_devices
# ============================================================
if any(w in cmd for w in [
"покажи", "список устройств", "какие устройств",
"что есть", "инвентар", "все устройств",
]):
target = self._resolve_domain(cmd)
return self.list_devices(domain=target or "", limit=80)
# ============================================================
# Общий отчёт
# ============================================================
if "отчет" in cmd or "отчёт" in cmd or "обзор" in cmd or "все вместе" in cmd:
return "\n".join(self._monitor_full_report())
# ============================================================
# РЕЖИМ МОНИТОРИНГА (вопросы о состоянии)
# ============================================================
is_question = ( is_question = (
cmd.startswith(("как", "сколько", "что", "кто", "где", "какая", "какой", "какое", "какие")) cmd.startswith(("как", "сколько", "что", "кто", "где", "какая", "какой", "какое", "какие"))
or "?" in cmd or "?" in cmd
@@ -486,6 +639,14 @@ class Tools:
actions.extend(self._monitor_lights(tokens)) actions.extend(self._monitor_lights(tokens))
matched = True matched = True
elif any(k in cmd for k in ["воздух", "tvoc", "co2", "влажн", "качеств", "pm2", "pm10"]):
actions.extend(self._monitor_air_quality(tokens))
matched = True
elif any(k in cmd for k in ["движен", "двигает", "хот", "кто-то ход"]):
actions.extend(self._monitor_motion(tokens))
matched = True
elif any(k in cmd for k in ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"]): elif any(k in cmd for k in ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"]):
actions.extend(self._monitor_climate(tokens)) actions.extend(self._monitor_climate(tokens))
matched = True matched = True
@@ -517,9 +678,15 @@ class Tools:
" - выключи весь свет\n" " - выключи весь свет\n"
" - поставь 23 градуса в детской\n" " - поставь 23 градуса в детской\n"
" - открой шторы в гостиной\n" " - открой шторы в гостиной\n"
" - нажми кнопку в спальне\n"
" - какие окна открыты?\n" " - какие окна открыты?\n"
" - какая температура в спальне?\n" " - какая температура в спальне?\n"
" - кто дома?" " - как воздух в квартире?\n"
" - где есть движение?\n"
" - кто дома?\n"
" - батарейки садятся?\n"
" - покажи все binary_sensor\n"
" - отчёт по дому"
) )
if warnings: if warnings:
return "Не удалось выполнить. " + ". ".join(warnings) + "\n\n" + hint return "Не удалось выполнить. " + ". ".join(warnings) + "\n\n" + hint
@@ -555,7 +722,8 @@ class Tools:
is_global = not any( is_global = not any(
t in cmd for t in [ t in cmd for t in [
"спальн", "гостин", "кухн", "детск", "ванн", "спальн", "гостин", "кухн", "детск", "ванн",
"прихож", "коридор", "кабинет", "зал", "прихож", "коридор", "кабинет", "зал", "веранд",
"дима", "диан", "елен", "анатоли",
] ]
) and len(entities) > 1 ) and len(entities) > 1
@@ -591,8 +759,8 @@ 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:
warnings.append("розетки не найдены") warnings.append("розетки не найдены")
@@ -763,6 +931,18 @@ class Tools:
self._call_service("vacuum", "start", {"entity_id": e}) self._call_service("vacuum", "start", {"entity_id": e})
actions.append("пылесос уборка") actions.append("пылесос уборка")
# Кнопки (button.press)
if "кнопк" in cmd and any(w in cmd for w in ("нажм", "кликн", "тапн", "триггерн", "активир")):
entities = self._find_entities(tokens, "button")
if entities:
count = 0
for e in entities:
self._call_service("button", "press", {"entity_id": e})
count += 1
actions.append("кнопки нажаты (" + str(count) + " шт.)")
else:
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")
@@ -837,7 +1017,6 @@ class Tools:
# Фильтр по домену # Фильтр по домену
if domain: if domain:
if domain == "window_door": if domain == "window_door":
# Спецкатегория: всё что похоже на окно/дверь
if dc not in ("door", "window", "garage_door", "lock") and \ if dc not in ("door", "window", "garage_door", "lock") and \
not any(k in (eid + " " + (attrs.get("friendly_name", "") or "").lower()) not any(k in (eid + " " + (attrs.get("friendly_name", "") or "").lower())
for k in ["окн", "двер", "форточ", "балкон", "калитк", "ворота", "гараж"]): for k in ["окн", "двер", "форточ", "балкон", "калитк", "ворота", "гараж"]):