v2.4.0 — мониторинг воздуха/движения, фикс батареек, маршрутизация 'покажи', кнопки, общий отчёт
This commit is contained in:
+226
-47
@@ -2,7 +2,7 @@
|
||||
title: Home Assistant Smart Control
|
||||
description: Управление умным домом через Home Assistant REST API с интерпретацией естественного языка.
|
||||
author: ChatGPT (refactored by MiniMax-M3)
|
||||
version: 2.3.0
|
||||
version: 2.4.0
|
||||
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 = {
|
||||
@@ -102,6 +124,17 @@ class Tools:
|
||||
"domains": ["person", "device_tracker"],
|
||||
"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)
|
||||
|
||||
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
|
||||
|
||||
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]:
|
||||
"""Какие батарейки садятся."""
|
||||
"""Какая температура и состояние климата.
|
||||
Берём ТОЛЬКО climate.* или sensor с device_class=temperature/humidity.
|
||||
"""
|
||||
states = self._get_states()
|
||||
low_battery: List[Tuple[Dict[str, Any], float]] = []
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
for s in states:
|
||||
eid = s["entity_id"]
|
||||
device_class = self._attr(s, "device_class", "")
|
||||
unit = self._attr(s, "unit_of_measurement", "")
|
||||
dc = self._attr(s, "device_class", "")
|
||||
|
||||
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)
|
||||
)
|
||||
# Только climate или sensor с релевантным device_class
|
||||
if eid.startswith("climate."):
|
||||
items.append(s)
|
||||
elif eid.startswith("sensor.") and dc in (
|
||||
"temperature", "humidity", "current_temperature",
|
||||
):
|
||||
items.append(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
|
||||
try:
|
||||
level = float(cur)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if level < 30:
|
||||
low_battery.append((s, level))
|
||||
continue
|
||||
|
||||
try:
|
||||
level = float(s.get("state", ""))
|
||||
except (ValueError, TypeError):
|
||||
# Бинарный сенсор "батарея разряжена"
|
||||
if eid.startswith("binary_sensor.") and dc == "battery":
|
||||
if cur == "on":
|
||||
binary_low.append(s)
|
||||
continue
|
||||
|
||||
if level < 30:
|
||||
low_battery.append((s, level))
|
||||
|
||||
if not low_battery:
|
||||
if not low_battery and not binary_low:
|
||||
return ["Все батарейки в норме (>=30%)"]
|
||||
|
||||
low_battery.sort(key=lambda x: x[1])
|
||||
lines = ["**Низкий заряд:**"]
|
||||
# Сортируем по возрастанию
|
||||
low_battery.sort(key=lambda x: x[1])
|
||||
for s, level in low_battery[:15]:
|
||||
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
|
||||
|
||||
def _monitor_media(self, tokens: List[str]) -> List[str]:
|
||||
@@ -448,6 +564,24 @@ class Tools:
|
||||
lines.append(" (информация отсутствует)")
|
||||
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] = []
|
||||
|
||||
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 = (
|
||||
cmd.startswith(("как", "сколько", "что", "кто", "где", "какая", "какой", "какое", "какие"))
|
||||
or "?" in cmd
|
||||
@@ -486,6 +639,14 @@ class Tools:
|
||||
actions.extend(self._monitor_lights(tokens))
|
||||
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 ["температур", "климат", "тепл", "холод", "кондиционер", "батаре"]):
|
||||
actions.extend(self._monitor_climate(tokens))
|
||||
matched = True
|
||||
@@ -517,9 +678,15 @@ class Tools:
|
||||
" - выключи весь свет\n"
|
||||
" - поставь 23 градуса в детской\n"
|
||||
" - открой шторы в гостиной\n"
|
||||
" - нажми кнопку в спальне\n"
|
||||
" - какие окна открыты?\n"
|
||||
" - какая температура в спальне?\n"
|
||||
" - кто дома?"
|
||||
" - как воздух в квартире?\n"
|
||||
" - где есть движение?\n"
|
||||
" - кто дома?\n"
|
||||
" - батарейки садятся?\n"
|
||||
" - покажи все binary_sensor\n"
|
||||
" - отчёт по дому"
|
||||
)
|
||||
if warnings:
|
||||
return "Не удалось выполнить. " + ". ".join(warnings) + "\n\n" + hint
|
||||
@@ -555,7 +722,8 @@ class Tools:
|
||||
is_global = not any(
|
||||
t in cmd for t in [
|
||||
"спальн", "гостин", "кухн", "детск", "ванн",
|
||||
"прихож", "коридор", "кабинет", "зал",
|
||||
"прихож", "коридор", "кабинет", "зал", "веранд",
|
||||
"дима", "диан", "елен", "анатоли",
|
||||
]
|
||||
) and len(entities) > 1
|
||||
|
||||
@@ -591,8 +759,8 @@ class Tools:
|
||||
label = "выключен" if turn_off else "включён"
|
||||
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")
|
||||
if not entities:
|
||||
warnings.append("розетки не найдены")
|
||||
@@ -763,6 +931,18 @@ class Tools:
|
||||
self._call_service("vacuum", "start", {"entity_id": e})
|
||||
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 ["сцен", "режим", "запуст"]):
|
||||
all_scripts = self._find_entities([], "script")
|
||||
@@ -837,7 +1017,6 @@ class Tools:
|
||||
# Фильтр по домену
|
||||
if domain:
|
||||
if domain == "window_door":
|
||||
# Спецкатегория: всё что похоже на окно/дверь
|
||||
if dc not in ("door", "window", "garage_door", "lock") and \
|
||||
not any(k in (eid + " " + (attrs.get("friendly_name", "") or "").lower())
|
||||
for k in ["окн", "двер", "форточ", "балкон", "калитк", "ворота", "гараж"]):
|
||||
|
||||
Reference in New Issue
Block a user