diff --git a/custom_components/rtkey/__init__.py b/custom_components/rtkey/__init__.py new file mode 100644 index 0000000..02beb3e --- /dev/null +++ b/custom_components/rtkey/__init__.py @@ -0,0 +1,687 @@ +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) \ No newline at end of file diff --git a/custom_components/rtkey/button.py b/custom_components/rtkey/button.py new file mode 100644 index 0000000..1a93ada --- /dev/null +++ b/custom_components/rtkey/button.py @@ -0,0 +1,93 @@ +from typing import Any + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo + +from . import DOMAIN, RTKeyCamerasApi, _LOGGER + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Any, +) -> None: + cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"] + intercoms_info = await cameras_api.get_intercoms_info() + + entities = [ + RTKeyOpenButton(hass, config_entry, cameras_api, intercom_info) + for intercom_info in intercoms_info.get("data", {}).get("devices", []) + ] + async_add_entities(entities) + + +class RTKeyOpenButton(ButtonEntity): + """Кнопка открытия домофона.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + cameras_api: RTKeyCamerasApi, + intercom_info: dict, + ) -> None: + self.hass = hass + self.cameras_api = cameras_api + self.intercom_id = str(intercom_info["id"]) + self.camera_id = intercom_info.get("camera_id") + + self.device_name = cameras_api.build_device_name( + intercom_info.get("name_by_user") or + intercom_info.get("description") or + intercom_info.get("name_by_company", "Домофон") + ) + + self._attr_unique_id = f"open_button_{self.intercom_id}" + self._attr_name = "Открыть" + self._attr_icon = "mdi:door-open" + + # Сохраняем информацию из API домофонов + self._device_serial = intercom_info.get("serial_number", "") + self._device_group = ", ".join(intercom_info.get("device_group", [])) + self._is_active = intercom_info.get("is_active", False) + self._capabilities = [ + cap.get("name") for cap in intercom_info.get("capabilities", []) if cap.get("setup") + ] + self._inter_codes_count = len(intercom_info.get("inter_codes", [])) + + async def async_press(self) -> None: + """Вызывается при нажатии на кнопку.""" + _LOGGER.info(f"🔘 Нажата кнопка открытия для {self.device_name}") + await self.cameras_api.open_intercom(self.intercom_id) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Атрибуты, которые будут видны в карточке сущности в HA.""" + attrs = { + "Статус": "Активен" if self._is_active else "Неактивен", + } + + if self._device_serial: + attrs["Серийный номер"] = self._device_serial + if self._device_group: + attrs["Группа"] = self._device_group + if self._inter_codes_count > 0: + attrs["Ключей доступа"] = self._inter_codes_count + if self._capabilities: + attrs["Возможности"] = ", ".join(self._capabilities[:5]) + if len(self._capabilities) > 5: + attrs["Возможности"] += f" (и еще {len(self._capabilities) - 5})" + + return attrs + + @property + def device_info(self) -> DeviceInfo: + device_id = self.camera_id or self.intercom_id + return { + "identifiers": {(DOMAIN, device_id)}, + "name": self.device_name, + "manufacturer": "RT Key", + "model": "Домофон", + } \ No newline at end of file diff --git a/custom_components/rtkey/camera.py b/custom_components/rtkey/camera.py new file mode 100644 index 0000000..79053b6 --- /dev/null +++ b/custom_components/rtkey/camera.py @@ -0,0 +1,236 @@ +import datetime +from typing import Any + +from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.entity import EntityCategory + +from . import _LOGGER, DOMAIN, RTKeyCamerasApi, TOKEN_REFRESH_BUFFER + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Any, +) -> None: + cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"] + cameras_info = await cameras_api.get_cameras_info() + + entities = [ + RTKeyCamera(hass, config_entry, cameras_api, camera_info) + for camera_info in cameras_info["data"]["items"] + ] + + # Добавляем архивные камеры событий для домофонов + if cameras_api.event_archive_enabled: + intercoms_info = await cameras_api.get_intercoms_info() + for intercom_info in intercoms_info.get("data", {}).get("devices", []): + entities.append( + RTKeyEventCamera(hass, config_entry, cameras_api, intercom_info) + ) + else: + _LOGGER.info("Архивные видео событий отключены") + + async_add_entities(entities) + + +class RTKeyCamera(Camera): + """Live-камера (для всех устройств — и камер, и домофонов).""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + cameras_api: RTKeyCamerasApi, + camera_info: dict, + ) -> None: + super().__init__() + + self.hass = hass + self.cameras_api = cameras_api + self.camera_id = camera_info["id"] + + self.device_name = cameras_api.build_device_name(camera_info.get("title", "Устройство")) + + self._device_model = camera_info.get("model", "") + self._device_vendor = camera_info.get("vendor", "") + self._device_serial = camera_info.get("serial_number", "") + self._device_mac = camera_info.get("mac", "") + self._device_ip = camera_info.get("ip", "") + self._device_status_title = camera_info.get("status_title", "Неизвестно") + + self._attr_unique_id = f"camera_{self.camera_id}" + self._attr_name = self.device_name + self._attr_supported_features = CameraEntityFeature.STREAM + + self._unsub_stream_refresh: Any = None + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + self._unsub_stream_refresh = async_track_time_interval( + self.hass, + self._stream_refresh, + datetime.timedelta(seconds=TOKEN_REFRESH_BUFFER), + ) + _LOGGER.debug(f"Таймер обновления стрима запущен для {self.device_name}") + + async def async_will_remove_from_hass(self) -> None: + if self._unsub_stream_refresh: + self._unsub_stream_refresh() + self._unsub_stream_refresh = None + await super().async_will_remove_from_hass() + + async def _stream_refresh(self, now: datetime.datetime) -> None: + try: + # Если токены протухли — обновляем их перед получением URL + if self.cameras_api._tokens_invalid: + _LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}") + await self.cameras_api.refresh_tokens() + + url = await self.stream_source() + if url and self.stream and getattr(self.stream, "source", None) != url: + _LOGGER.debug(f"Обновляем URL стрима для {self.device_name}") + self.stream.update_source(url) + except Exception as e: + _LOGGER.error(f"Ошибка обновления стрима для {self.camera_id}: {e}") + + async def stream_source(self) -> str | None: + return await self.cameras_api.get_camera_stream_url(self.camera_id) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + return await self.cameras_api.get_camera_image(self.camera_id) + + @property + def available(self) -> bool: + """Камера доступна, если она не в списке недоступных.""" + return self.cameras_api.is_camera_available(self.camera_id) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Атрибуты с динамической проверкой доступности камеры.""" + if self.cameras_api.is_camera_available(self.camera_id): + status = self._device_status_title + else: + status = "❌ Нет связи" + + attrs = {"Статус": status} + if self._device_model: + attrs["Модель"] = self._device_model + if self._device_serial: + attrs["Серийный номер"] = self._device_serial + if self._device_mac and self._device_mac != "00:00:00:00:00:00": + attrs["MAC"] = self._device_mac + if self._device_ip and self._device_ip != "0.0.0.0": + attrs["IP"] = self._device_ip + return attrs + + @property + def device_info(self) -> DeviceInfo: + info = { + "identifiers": {(DOMAIN, self.camera_id)}, + "name": self.device_name, + "manufacturer": self._device_vendor or "RT Key", + } + if self._device_model: + info["model"] = self._device_model + if self._device_serial: + info["serial_number"] = self._device_serial + return info + + +class RTKeyEventCamera(Camera): + """Камера для просмотра архивного видео на момент последнего события.""" + + _attr_should_poll = False + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + cameras_api: RTKeyCamerasApi, + intercom_info: dict, + ) -> None: + super().__init__() + + self.hass = hass + self.cameras_api = cameras_api + self.intercom_id = str(intercom_info["id"]) + self.camera_id = intercom_info.get("camera_id") + + self.device_name = cameras_api.build_device_name( + intercom_info.get("name_by_user") + or intercom_info.get("description") + or intercom_info.get("name_by_company", "Домофон") + ) + + self._attr_unique_id = f"event_camera_{self.intercom_id}" + self._attr_name = "Архив события" + self._attr_supported_features = CameraEntityFeature.STREAM + + self._event_description = None + self._event_time = None + self._archive_url = None + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self.cameras_api.register_event_listener(self._handle_event_update) + + if self.intercom_id in self.cameras_api.last_events: + self._update_from_event() + + async def async_will_remove_from_hass(self) -> None: + self.cameras_api.unregister_event_listener(self._handle_event_update) + await super().async_will_remove_from_hass() + + @callback + def _handle_event_update(self, intercom_id: str): + if intercom_id == self.intercom_id: + self._update_from_event() + + def _update_from_event(self): + event_info = self.cameras_api.last_events.get(self.intercom_id) + if not event_info: + return + + archive_url = event_info.get("archive_url") + if archive_url: + self._archive_url = archive_url + self._event_description = event_info.get("description") + self._event_time = event_info.get("local_time") + + if self.stream: + _LOGGER.debug(f"Обновляем URL архива для {self.device_name}") + self.stream.update_source(archive_url) + + self.async_write_ha_state() + + async def stream_source(self) -> str | None: + return self._archive_url + + @property + def extra_state_attributes(self) -> dict[str, Any]: + attrs = {} + if self._event_description: + attrs["Событие"] = self._event_description + if self._event_time: + attrs["Время"] = self._event_time + if self._archive_url: + attrs["URL архива"] = self._archive_url + return attrs + + @property + def device_info(self) -> DeviceInfo: + device_id = self.camera_id or self.intercom_id + return { + "identifiers": {(DOMAIN, device_id)}, + "name": self.device_name, + "manufacturer": "RT Key", + "model": "Домофон", + } \ No newline at end of file diff --git a/custom_components/rtkey/config_flow.py b/custom_components/rtkey/config_flow.py new file mode 100644 index 0000000..020b862 --- /dev/null +++ b/custom_components/rtkey/config_flow.py @@ -0,0 +1,74 @@ +import voluptuous as vol +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.core import callback + +from . import ( + CONF_NAME, + CONF_TOKEN, + CONF_CAMERA_IMAGE_REFRESH_INTERVAL, + CONF_SCREENSHOT_QUALITY, + CONF_LOG_LEVEL, + CONF_EVENTS_ENABLED, + CONF_EVENTS_REFRESH_INTERVAL, + CONF_EVENT_TIME_LAG, + CONF_PRE_EVENT_SECONDS, + CONF_EVENT_SCREENSHOT_ENABLED, + CONF_EVENT_SCREENSHOT_QUALITY, + CONF_EVENT_ARCHIVE_ENABLED, + CONF_ARCHIVE_DURATION, + CONF_EVENT_SENSOR_ENABLED, + DATA_SCHEMA, + OPTIONS_SCHEMA, + DOMAIN, +) + + +class RTKeyOptionsFlow(OptionsFlowWithConfigEntry): + async def async_step_init(self, user_input=None): + if user_input is not None: + return self.async_create_entry( + title=self.config_entry.data.get(CONF_NAME, "RT Key"), + data=user_input, + ) + + current_options = dict(self.options) + if CONF_TOKEN not in current_options and CONF_TOKEN in self.config_entry.data: + current_options[CONF_TOKEN] = self.config_entry.data[CONF_TOKEN] + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(OPTIONS_SCHEMA), current_options + ), + ) + + +class RTKeyConfigFlow(ConfigFlow, domain=DOMAIN): + VERSION = 1 + MINOR_VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> RTKeyOptionsFlow: + return RTKeyOptionsFlow(config_entry) + + async def async_step_user(self, user_input=None): + if user_input is not None: + return self.async_create_entry( + title=user_input.get(CONF_NAME, "RT Key"), + data=user_input, + options=user_input, + ) + + # ✅ Добавляем add_suggested_values_to_schema для корректной работы переводов + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(DATA_SCHEMA).extend(OPTIONS_SCHEMA), + {}, # Пустые значения по умолчанию для первой настройки + ), + ) \ No newline at end of file diff --git a/custom_components/rtkey/icon.png b/custom_components/rtkey/icon.png new file mode 100644 index 0000000..a76898a Binary files /dev/null and b/custom_components/rtkey/icon.png differ