import asyncio import functools import logging import time from datetime import datetime, timezone from zoneinfo import ZoneInfo import requests import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import selector DOMAIN = "rtkey" PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) # === Константы настроек === CONF_NAME = "name" CONF_TOKEN = "token" CONF_CAMERA_IMAGE_REFRESH_INTERVAL = "camera_image_refresh_interval" CONF_SCREENSHOT_QUALITY = "screenshot_quality" CONF_LOG_LEVEL = "log_level" # Настройки событий CONF_EVENTS_ENABLED = "events_enabled" CONF_EVENTS_REFRESH_INTERVAL = "events_refresh_interval" CONF_EVENT_TIME_LAG = "event_time_lag" CONF_PRE_EVENT_SECONDS = "pre_event_seconds" CONF_EVENT_SCREENSHOT_ENABLED = "event_screenshot_enabled" CONF_EVENT_SCREENSHOT_QUALITY = "event_screenshot_quality" CONF_EVENT_ARCHIVE_ENABLED = "event_archive_enabled" CONF_ARCHIVE_DURATION = "archive_duration" CONF_EVENT_SENSOR_ENABLED = "event_sensor_enabled" TOKEN_REFRESH_BUFFER = 300 DATA_SCHEMA = { vol.Required(CONF_NAME): str, vol.Required(CONF_TOKEN): str, } OPTIONS_SCHEMA = { vol.Optional(CONF_NAME): str, vol.Optional(CONF_TOKEN): str, vol.Optional(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, default=60): selector.NumberSelector( selector.NumberSelectorConfig( min=0, max=600, step=30, mode=selector.NumberSelectorMode.SLIDER, unit_of_measurement="сек", ) ), vol.Optional(CONF_SCREENSHOT_QUALITY, default="medium"): selector.SelectSelector( selector.SelectSelectorConfig( options=[ selector.SelectOptionDict(value="small", label="Маленькое (быстрее)"), selector.SelectOptionDict(value="medium", label="Среднее"), selector.SelectOptionDict(value="large", label="Большое"), selector.SelectOptionDict(value="precise", label="Точное (медленнее)"), ], mode=selector.SelectSelectorMode.DROPDOWN, ) ), vol.Optional(CONF_LOG_LEVEL, default="INFO"): selector.SelectSelector( selector.SelectSelectorConfig( options=[ selector.SelectOptionDict(value="DEBUG", label="DEBUG (подробно)"), selector.SelectOptionDict(value="INFO", label="INFO (обычный)"), selector.SelectOptionDict(value="WARNING", label="WARNING (только предупреждения)"), selector.SelectOptionDict(value="ERROR", label="ERROR (только ошибки)"), ], mode=selector.SelectSelectorMode.DROPDOWN, ) ), vol.Optional(CONF_EVENTS_ENABLED, default=True): bool, vol.Optional(CONF_EVENTS_REFRESH_INTERVAL, default=15): selector.NumberSelector( selector.NumberSelectorConfig( min=5, max=60, step=5, mode=selector.NumberSelectorMode.SLIDER, unit_of_measurement="сек", ) ), vol.Optional(CONF_EVENT_TIME_LAG, default=25): selector.NumberSelector( selector.NumberSelectorConfig( min=0, max=60, step=5, mode=selector.NumberSelectorMode.SLIDER, unit_of_measurement="сек", ) ), vol.Optional(CONF_PRE_EVENT_SECONDS, default=10): selector.NumberSelector( selector.NumberSelectorConfig( min=0, max=30, step=5, mode=selector.NumberSelectorMode.SLIDER, unit_of_measurement="сек", ) ), vol.Optional(CONF_EVENT_SCREENSHOT_ENABLED, default=True): bool, vol.Optional(CONF_EVENT_SCREENSHOT_QUALITY, default="large"): selector.SelectSelector( selector.SelectSelectorConfig( options=[ selector.SelectOptionDict(value="small", label="Маленькое"), selector.SelectOptionDict(value="medium", label="Среднее"), selector.SelectOptionDict(value="large", label="Большое"), selector.SelectOptionDict(value="precise", label="Точное"), ], mode=selector.SelectSelectorMode.DROPDOWN, ) ), vol.Optional(CONF_EVENT_ARCHIVE_ENABLED, default=True): bool, vol.Optional(CONF_ARCHIVE_DURATION, default=30): selector.NumberSelector( selector.NumberSelectorConfig( min=10, max=120, step=10, mode=selector.NumberSelectorMode.SLIDER, unit_of_measurement="сек", ) ), vol.Optional(CONF_EVENT_SENSOR_ENABLED, default=True): bool, } class RTKeyCamerasApi: def __init__(self, hass: HomeAssistant, entry: ConfigEntry): self.hass = hass self.entry = entry self.token = entry.options.get(CONF_TOKEN) or entry.data.get(CONF_TOKEN, "") self.screenshot_quality = entry.options.get(CONF_SCREENSHOT_QUALITY, "medium") # Настройки событий self.events_enabled = entry.options.get(CONF_EVENTS_ENABLED, True) self.events_refresh_interval = entry.options.get(CONF_EVENTS_REFRESH_INTERVAL, 15) self.event_time_lag = entry.options.get(CONF_EVENT_TIME_LAG, 25) self.pre_event_seconds = entry.options.get(CONF_PRE_EVENT_SECONDS, 10) self.event_screenshot_enabled = entry.options.get(CONF_EVENT_SCREENSHOT_ENABLED, True) self.event_screenshot_quality = entry.options.get(CONF_EVENT_SCREENSHOT_QUALITY, "large") self.event_archive_enabled = entry.options.get(CONF_EVENT_ARCHIVE_ENABLED, True) self.archive_duration = entry.options.get(CONF_ARCHIVE_DURATION, 30) self.event_sensor_enabled = entry.options.get(CONF_EVENT_SENSOR_ENABLED, True) log_level = entry.options.get(CONF_LOG_LEVEL, "INFO") _LOGGER.setLevel(getattr(logging, log_level, logging.INFO)) _LOGGER.info(f"📊 Уровень логирования: {log_level}") self.user_token = None self.lock = asyncio.Lock() # Информация о камерах self.camera_tokens = {} self._cameras_cache = None self._cameras_cache_ts = 0 # Кэш картинок self._image_cache = {} self._image_cache_lock = asyncio.Lock() # ✅ НОВОЕ: Отслеживание недоступных камер (404) self._unavailable_cameras: set[str] = set() # ✅ НОВОЕ: Флаг протухших токенов (403) self._tokens_invalid = False self._tokens_refresh_lock = asyncio.Lock() # Данные событий self.last_events = {} self._last_request_ids = {} self.rfid_names = {} self.intercom_camera_map = {} self.intercom_ids = [] self._events_polling_task: asyncio.Task | None = None self._event_listeners = [] self._local_tz = ZoneInfo(hass.config.time_zone) _LOGGER.info(f"🕐 Временная зона HA: {hass.config.time_zone}") def build_device_name(self, name: str) -> str: return name.strip() if name else "RT Key Device" def get_camera_name(self, camera_id: str) -> str: """Получаем название камеры по её ID.""" token_info = self.camera_tokens.get(camera_id) if token_info: return token_info.get("title", "Неизвестная камера") return "Неизвестная камера" def is_camera_available(self, camera_id: str) -> bool: """Проверяем, доступна ли камера (не в списке недоступных).""" return camera_id not in self._unavailable_cameras def register_event_listener(self, callback): if callback not in self._event_listeners: self._event_listeners.append(callback) def unregister_event_listener(self, callback): if callback in self._event_listeners: self._event_listeners.remove(callback) def _notify_event_listeners(self, intercom_id: str): for callback in self._event_listeners: try: callback(intercom_id) except Exception as e: _LOGGER.error(f"Ошибка в event listener: {e}") async def _fetch_cameras(self): """Получаем информацию о камерах (токены для стрима и скриншотов).""" if self._cameras_cache and (time.time() - self._cameras_cache_ts < 60): return self._cameras_cache try: _LOGGER.debug("Запрос camera_video_data/list...") r = await self.hass.async_add_executor_job( functools.partial( requests.get, "https://keyapis.key.rt.ru/vc/api/v1/camera_video_data/list?paging.limit=100&paging.offset=0", headers={"Authorization": f"Bearer {self.token}"}, timeout=20, ) ) if r.status_code == 200: data = r.json() devices = data.get("data", []) if devices: self.user_token = devices[0].get("userToken") for device in devices: uid = device.get("uid") if uid: self.camera_tokens[uid] = { "title": device.get("title", "Устройство"), "category_type": device.get("category", {}).get("type", "unknown"), "category_title": device.get("category", {}).get("title", ""), "model": device.get("model", ""), "vendor": device.get("vendor", ""), "serial_number": device.get("serialNumber", ""), "mac": device.get("mac", ""), "ip": device.get("ip", ""), "status_type": device.get("status", {}).get("type", "unknown"), "status_title": device.get("status", {}).get("title", ""), "streamer_token": device.get("streamerToken"), "screenshot_token": device.get("screenshotToken"), "user_token": device.get("userToken"), } self._cameras_cache = devices self._cameras_cache_ts = time.time() _LOGGER.info(f"✅ Загружено {len(self.camera_tokens)} устройств") return devices else: _LOGGER.error(f"camera_video_data/list вернул {r.status_code}") except Exception as e: _LOGGER.error(f"Ошибка получения камер: {e}") return [] async def refresh_tokens(self): """🔄 Принудительно обновляем все токены (сбрасываем кэш).""" async with self._tokens_refresh_lock: _LOGGER.info("🔄 Принудительное обновление токенов...") self._cameras_cache = None self._cameras_cache_ts = 0 await self._fetch_cameras() self._tokens_invalid = False _LOGGER.info(f"✅ Токены обновлены для {len(self.camera_tokens)} устройств") async def _fetch_intercoms(self): """Получаем информацию о домофонах из отдельного API.""" try: _LOGGER.debug("Запрос домофонов из household.key.rt.ru...") r = await self.hass.async_add_executor_job( functools.partial( requests.get, "https://household.key.rt.ru/api/v2/app/devices/intercom", headers={"Authorization": f"Bearer {self.token}"}, timeout=15, ) ) if r.status_code == 200: data = r.json() self._build_intercom_mappings(data) devices = data.get("data", {}).get("devices", []) self.intercom_ids = [str(d.get("id")) for d in devices if d.get("id")] _LOGGER.info(f"✅ Загружено {len(self.intercom_ids)} домофонов: {self.intercom_ids}") return data else: _LOGGER.error(f"intercom вернул {r.status_code}") except Exception as e: _LOGGER.error(f"Ошибка получения домофонов: {e}") return {"data": {"devices": []}} def _build_intercom_mappings(self, data: dict): devices = data.get("data", {}).get("devices", []) for device in devices: intercom_id = device.get("id") camera_id = device.get("camera_id") if intercom_id and camera_id: self.intercom_camera_map[str(intercom_id)] = camera_id for code in device.get("inter_codes", []): rfid_id = code.get("id") name = code.get("name_by_user", "") if rfid_id and name: self.rfid_names[rfid_id] = name _LOGGER.debug( f"Построены маппинги: {len(self.intercom_camera_map)} камер домофонов, " f"{len(self.rfid_names)} ключей" ) async def get_cameras_info(self): await self._fetch_cameras() items = [] for uid, info in self.camera_tokens.items(): items.append({ "id": uid, "title": info["title"], "category_type": info["category_type"], "status_title": info["status_title"], "model": info["model"], "vendor": info["vendor"], "serial_number": info["serial_number"], "mac": info["mac"], "ip": info["ip"], # ✅ Добавляем флаг доступности "available": self.is_camera_available(uid), }) return {"data": {"items": items}} async def get_intercoms_info(self): if not self.intercom_ids: return await self._fetch_intercoms() return await self._fetch_intercoms() async def get_camera_image(self, camera_id: str, retry: bool = False) -> bytes | None: """Получаем скриншот камеры с обработкой ошибок 403 и 404.""" async with self._image_cache_lock: if camera_id in self._image_cache: img, ts = self._image_cache[camera_id] if time.time() - ts < 4: return img token_info = self.camera_tokens.get(camera_id) screenshot_token = token_info.get("screenshot_token") if token_info else None user_token = (token_info.get("user_token") or self.user_token) if token_info else None # ✅ Получаем название камеры для логов camera_name = self.get_camera_name(camera_id) try: url = f"https://key.rt.ru/screenshot/image/{self.screenshot_quality}/{camera_id}/last.jpg" if screenshot_token: url += f"?token={screenshot_token}&_={int(time.time() * 1000)}" _LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id}): {url}") r = await self.hass.async_add_executor_job( functools.partial( requests.get, url, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://key.rt.ru/", "Origin": "https://key.rt.ru", "X-UTOKEN": user_token or self.token, }, timeout=15, ) ) # ✅ Обработка 403 — токен протух if r.status_code == 403 and not retry: _LOGGER.warning( f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..." ) self._tokens_invalid = True await self.refresh_tokens() return await self.get_camera_image(camera_id, retry=True) if r.status_code == 403 and retry: _LOGGER.error( f"❌ Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией" ) return None # ✅ Обработка 404 — камера недоступна if r.status_code == 404: if camera_id not in self._unavailable_cameras: _LOGGER.warning( f"📷 Камера \"{camera_name}\" ({camera_id}) недоступна (404), помечаем как offline" ) self._unavailable_cameras.add(camera_id) return None if r.status_code == 200 and len(r.content) > 2000: image_data = r.content async with self._image_cache_lock: self._image_cache[camera_id] = (image_data, time.time()) # ✅ Если камера была недоступна — помечаем как восстановленную if camera_id in self._unavailable_cameras: self._unavailable_cameras.discard(camera_id) _LOGGER.info(f"✅ Камера \"{camera_name}\" ({camera_id}) снова доступна") _LOGGER.info(f"✅ Скриншот получен {camera_name} ({camera_id}) — {len(image_data)} байт") return image_data else: _LOGGER.warning( f"Скриншот для {camera_name} ({camera_id}): статус {r.status_code}, размер {len(r.content)}" ) except Exception as e: _LOGGER.error(f"Ошибка скриншота {camera_name} ({camera_id}): {e}") return None async def get_event_screenshot(self, camera_id: str, event_timestamp: int) -> bytes | None: """Получаем скриншот на момент события (с учётом лага).""" token_info = self.camera_tokens.get(camera_id) screenshot_token = token_info.get("screenshot_token") if token_info else None user_token = (token_info.get("user_token") or self.user_token) if token_info else None adjusted_timestamp = event_timestamp - self.event_time_lag url = f"https://key.rt.ru/screenshot/image/{self.event_screenshot_quality}/{camera_id}/{adjusted_timestamp}.jpg" if screenshot_token: url += f"?token={screenshot_token}" try: _LOGGER.debug(f"Запрос скриншота события для {camera_id}: {url}") r = await self.hass.async_add_executor_job( functools.partial( requests.get, url, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://key.rt.ru/", "Origin": "https://key.rt.ru", "X-UTOKEN": user_token or self.token, }, timeout=15, ) ) if r.status_code == 200 and len(r.content) > 1000: _LOGGER.info(f"✅ Скриншот события получен {camera_id} ({len(r.content)} байт)") return r.content else: _LOGGER.warning(f"Скриншот события {camera_id}: статус {r.status_code}") except Exception as e: _LOGGER.error(f"Ошибка скриншота события {camera_id}: {e}") return None def get_event_archive_url(self, camera_id: str, event_timestamp: int) -> str | None: token_info = self.camera_tokens.get(camera_id) streamer_token = token_info.get("streamer_token") if token_info else None adjusted_timestamp = event_timestamp - self.event_time_lag - self.pre_event_seconds base = f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/{adjusted_timestamp}.mp4" params = "mp4-fragment-length=0.5&mp4-use-speed=0&mp4-afiller=1" if streamer_token: return f"{base}?{params}&token={streamer_token}" return f"{base}?{params}" async def get_camera_stream_url(self, camera_id: str) -> str | None: token_info = self.camera_tokens.get(camera_id) streamer_token = token_info.get("streamer_token") if token_info else None base = f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/live.mp4" params = "mp4-fragment-length=0.5&mp4-use-speed=0&mp4-afiller=1" if streamer_token: return f"{base}?{params}&token={streamer_token}" return f"{base}?{params}" async def open_intercom(self, intercom_id: str): try: await self.hass.async_add_executor_job( functools.partial( requests.post, f"https://household.key.rt.ru/api/v2/app/devices/{intercom_id}/open", headers={"Authorization": f"Bearer {self.token}"}, json={}, timeout=10, ) ) _LOGGER.info(f"✅ Открыт домофон {intercom_id}") except Exception as e: _LOGGER.error(f"Ошибка открытия {intercom_id}: {e}") def _parse_iso_to_timestamp(self, iso_str: str) -> int: try: dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) return int(dt.timestamp()) except Exception as e: _LOGGER.error(f"Ошибка парсинга времени {iso_str}: {e}") return 0 def _format_local_time(self, iso_str: str) -> str: try: dt_utc = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) dt_local = dt_utc.astimezone(self._local_tz) return dt_local.strftime("%Y-%m-%d %H:%M:%S") except Exception as e: _LOGGER.error(f"Ошибка форматирования времени {iso_str}: {e}") return iso_str def _get_event_description(self, event: dict) -> str: event_type = event.get("event_type", "") rfid_id = event.get("rfid_id") if rfid_id and rfid_id in self.rfid_names: return self.rfid_names[rfid_id] descriptions = { "api_open_remote": "Открытие через приложение", "face_open_remote": "Открытие по лицу", "pin_code_open_remote": "Открытие по пин-коду", "code_open_local": "Открытие по коду", "rfid_open_local": "Открытие по ключу", "dtmf_open_local": "Открытие через телефон", } if event.get("rfid") and event_type == "rfid_open_local": return f"Ключ {event.get('rfid')}" return descriptions.get(event_type, event_type) async def get_last_event(self, intercom_id: str) -> dict | None: try: now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") url = ( f"https://events.key.rt.ru/api/v2/events/list?" f"begin_raised_at=2018-01-01T00:00:00Z&" f"end_raised_at={now_iso}&" f"device_ids={intercom_id}&" f"limit=1&" f"sort_by=raised_at&" f"offset=0" ) r = await self.hass.async_add_executor_job( functools.partial( requests.get, url, headers={ "Authorization": f"Bearer {self.token}", "Accept": "application/json", }, timeout=15, ) ) if r.status_code == 200: data = r.json() items = data.get("data", {}).get("items", []) if items: return items[0] else: _LOGGER.warning(f"Events API вернул {r.status_code} для {intercom_id}") except Exception as e: _LOGGER.error(f"Ошибка получения событий для {intercom_id}: {e}") return None async def update_last_event(self, intercom_id: str, event: dict): camera_id = self.intercom_camera_map.get(str(intercom_id)) if not camera_id: _LOGGER.warning(f"Не найден camera_id для домофона {intercom_id}") return event_timestamp = self._parse_iso_to_timestamp(event.get("raised_at", "")) if not event_timestamp: return screenshot_data = None if self.event_screenshot_enabled: screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp) archive_url = None if self.event_archive_enabled: archive_url = self.get_event_archive_url(camera_id, event_timestamp) self.last_events[intercom_id] = { "event": event, "camera_id": camera_id, "timestamp": event_timestamp, "local_time": self._format_local_time(event.get("raised_at", "")), "description": self._get_event_description(event), "event_type": event.get("event_type", ""), "screenshot_data": screenshot_data, "archive_url": archive_url, } _LOGGER.info( f"🔔 Новое событие для домофона {intercom_id}: " f"{self.last_events[intercom_id]['description']} " f"в {self.last_events[intercom_id]['local_time']}" ) self._notify_event_listeners(intercom_id) async def _events_polling_loop(self): _LOGGER.info(f"🔄 Запущен цикл опроса событий (интервал {self.events_refresh_interval}с)") while True: try: for intercom_id in self.intercom_ids: last_event = await self.get_last_event(intercom_id) if not last_event: continue request_id = last_event.get("request_id") last_request_id = self._last_request_ids.get(intercom_id) if request_id and request_id != last_request_id: self._last_request_ids[intercom_id] = request_id await self.update_last_event(intercom_id, last_event) except Exception as e: _LOGGER.error(f"Ошибка в цикле опроса событий: {e}") await asyncio.sleep(self.events_refresh_interval) async def start_events_polling(self): if not self.events_enabled: _LOGGER.info("Отслеживание событий отключено") return if self._events_polling_task and not self._events_polling_task.done(): return await self._fetch_intercoms() self._events_polling_task = asyncio.create_task(self._events_polling_loop()) async def stop_events_polling(self): if self._events_polling_task and not self._events_polling_task.done(): self._events_polling_task.cancel() try: await self._events_polling_task except asyncio.CancelledError: pass self._events_polling_task = None async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: cameras_api = RTKeyCamerasApi(hass, entry) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "cameras_api": cameras_api } entry.add_update_listener(update_listener) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await cameras_api.start_events_polling() return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: cameras_api = hass.data[DOMAIN][entry.entry_id]["cameras_api"] await cameras_api.stop_events_polling() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id)