From dc349d0c9031e5b5d26c92831669608459da298a Mon Sep 17 00:00:00 2001 From: host Date: Wed, 29 Jul 2026 19:51:54 +0300 Subject: [PATCH] =?UTF-8?q?v2.4.0=20=E2=80=94=20=D0=BC=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D0=B2=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D1=83=D1=85=D0=B0/=D0=B4=D0=B2=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F,=20=D1=84=D0=B8=D0=BA=D1=81=20=D0=B1=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B5=D0=B5=D0=BA,=20=D0=BC=D0=B0=D1=80=D1=88=D1=80?= =?UTF-8?q?=D1=83=D1=82=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20'=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B6=D0=B8',=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D1=91=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- home_assistant_tools.py | 273 +++++++++++++++++++++++++++++++++------- 1 file changed, 226 insertions(+), 47 deletions(-) diff --git a/home_assistant_tools.py b/home_assistant_tools.py index 2e5d0fb..a2893d7 100644 --- a/home_assistant_tools.py +++ b/home_assistant_tools.py @@ -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 ["окн", "двер", "форточ", "балкон", "калитк", "ворота", "гараж"]):