diff --git a/custom_components/rtkey/__init__.py b/custom_components/rtkey/__init__.py index 02beb3e..08fce00 100644 --- a/custom_components/rtkey/__init__.py +++ b/custom_components/rtkey/__init__.py @@ -3,11 +3,10 @@ import functools import logging import time from datetime import datetime, timezone +from pathlib import Path 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 @@ -15,7 +14,6 @@ from homeassistant.helpers import selector DOMAIN = "rtkey" PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR] - _LOGGER = logging.getLogger(__name__) # === Константы настроек === @@ -30,12 +28,14 @@ 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" +# Новые настройки для локального хранения +CONF_ARCHIVE_PATH = "archive_path" +CONF_ARCHIVE_COPIES = "archive_copies" +CONF_SCREENSHOT_COPIES = "screenshot_copies" + TOKEN_REFRESH_BUFFER = 300 DATA_SCHEMA = { @@ -46,7 +46,6 @@ DATA_SCHEMA = { 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, @@ -54,7 +53,6 @@ OPTIONS_SCHEMA = { unit_of_measurement="сек", ) ), - vol.Optional(CONF_SCREENSHOT_QUALITY, default="medium"): selector.SelectSelector( selector.SelectSelectorConfig( options=[ @@ -66,7 +64,6 @@ OPTIONS_SCHEMA = { mode=selector.SelectSelectorMode.DROPDOWN, ) ), - vol.Optional(CONF_LOG_LEVEL, default="INFO"): selector.SelectSelector( selector.SelectSelectorConfig( options=[ @@ -78,9 +75,7 @@ OPTIONS_SCHEMA = { 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, @@ -88,7 +83,6 @@ OPTIONS_SCHEMA = { unit_of_measurement="сек", ) ), - vol.Optional(CONF_EVENT_TIME_LAG, default=25): selector.NumberSelector( selector.NumberSelectorConfig( min=0, max=60, step=5, @@ -96,7 +90,6 @@ OPTIONS_SCHEMA = { unit_of_measurement="сек", ) ), - vol.Optional(CONF_PRE_EVENT_SECONDS, default=10): selector.NumberSelector( selector.NumberSelectorConfig( min=0, max=30, step=5, @@ -104,9 +97,6 @@ OPTIONS_SCHEMA = { 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=[ @@ -118,17 +108,20 @@ OPTIONS_SCHEMA = { mode=selector.SelectSelectorMode.DROPDOWN, ) ), - - vol.Optional(CONF_EVENT_ARCHIVE_ENABLED, default=True): bool, - - vol.Optional(CONF_ARCHIVE_DURATION, default=30): selector.NumberSelector( + # 🆕 Настройки локального хранения + vol.Optional(CONF_ARCHIVE_PATH, default="media/RTKey"): str, + vol.Optional(CONF_ARCHIVE_COPIES, default=5): selector.NumberSelector( selector.NumberSelectorConfig( - min=10, max=120, step=10, - mode=selector.NumberSelectorMode.SLIDER, - unit_of_measurement="сек", + min=0, max=10, step=1, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Optional(CONF_SCREENSHOT_COPIES, default=10): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=1, + mode=selector.NumberSelectorMode.BOX, ) ), - vol.Optional(CONF_EVENT_SENSOR_ENABLED, default=True): bool, } @@ -145,32 +138,34 @@ class RTKeyCamerasApi: 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) - + + # 🆕 Настройки локального хранения + self.archive_path = entry.options.get(CONF_ARCHIVE_PATH, "media/RTKey") + self.archive_copies = entry.options.get(CONF_ARCHIVE_COPIES, 5) + self.screenshot_copies = entry.options.get(CONF_SCREENSHOT_COPIES, 10) + 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) + # Отслеживание недоступных камер (404) self._unavailable_cameras: set[str] = set() - # ✅ НОВОЕ: Флаг протухших токенов (403) + # Флаг протухших токенов (403) self._tokens_invalid = False self._tokens_refresh_lock = asyncio.Lock() @@ -185,21 +180,171 @@ class RTKeyCamerasApi: self._local_tz = ZoneInfo(hass.config.time_zone) _LOGGER.info(f"🕐 Временная зона HA: {hass.config.time_zone}") - + + # 🆕 Проверка FFmpeg + self.ffmpeg_available = False + + async def async_initialize(self): + """Инициализация после создания (для async операций).""" + await self._check_ffmpeg() + + async def _check_ffmpeg(self): + """Проверяем наличие FFmpeg в системе.""" + try: + proc = await asyncio.create_subprocess_exec( + "ffmpeg", "-version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.communicate() + self.ffmpeg_available = True + _LOGGER.info("✅ FFmpeg доступен") + except FileNotFoundError: + self.ffmpeg_available = False + _LOGGER.error("❌ FFmpeg не найден! Архивное видео будет недоступно.") + except Exception as e: + self.ffmpeg_available = False + _LOGGER.error(f"❌ Ошибка проверки FFmpeg: {e}") + + def _get_media_base_path(self) -> Path: + """Получаем базовый путь для медиафайлов.""" + # HA хранит медиа в /media/ (media_dirs) + media_dirs = self.hass.config.media_dirs + if media_dirs and "media" in media_dirs: + return Path(media_dirs["media"]) + # Fallback + return Path("/media") + + def _get_archive_dir(self, intercom_id: str) -> Path: + """Путь к папке архивов для домофона.""" + return self._get_media_base_path() / self.archive_path / "archives" / intercom_id + + def _get_screenshot_dir(self, intercom_id: str) -> Path: + """Путь к папке скриншотов для домофона.""" + return self._get_media_base_path() / self.archive_path / "screenshots" / intercom_id + + async def download_event_archive(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None: + """Скачиваем архивное видео через FFmpeg и сохраняем локально.""" + if not self.ffmpeg_available: + _LOGGER.error("FFmpeg недоступен, пропускаем скачивание архива") + return None + + url = self.get_event_archive_url(camera_id, event_timestamp) + if not url: + _LOGGER.error(f"Не удалось получить URL архива для камеры {camera_id}") + return None + + # Создаём папку + archive_dir = self._get_archive_dir(intercom_id) + archive_dir.mkdir(parents=True, exist_ok=True) + + # Имя файла + dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz) + filename = dt.strftime("%Y-%m-%d_%H-%M-%S.mp4") + filepath = archive_dir / filename + + # Вычисляем длительность: pre_event + lag + post_event (archive_duration) + # archive_duration по умолчанию 30 сек + archive_duration = 30 # Можно вынести в настройки позже + duration = self.pre_event_seconds + self.event_time_lag + archive_duration + + _LOGGER.info(f" Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}") + + cmd = [ + "ffmpeg", "-y", + "-i", url, + "-t", str(duration), + "-c", "copy", + str(filepath), + ] + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=duration + 30 # Запас на задержки сети + ) + + if proc.returncode == 0 and filepath.exists() and filepath.stat().st_size > 1000: + _LOGGER.info(f"✅ Архив сохранён: {filepath} ({filepath.stat().st_size} байт)") + self.cleanup_old_archives(intercom_id) + return str(filepath) + else: + error_msg = stderr.decode()[:200] if stderr else "Неизвестная ошибка" + _LOGGER.error(f"❌ Ошибка FFmpeg для {intercom_id}: {error_msg}") + return None + + except asyncio.TimeoutError: + _LOGGER.error(f"⏱️ Превышено время скачивания архива для {intercom_id}") + if proc: + proc.kill() + return None + except Exception as e: + _LOGGER.error(f"Ошибка скачивания архива для {intercom_id}: {e}") + return None + + async def download_event_screenshot(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None: + """Скачиваем скриншот события и сохраняем локально.""" + screenshot_dir = self._get_screenshot_dir(intercom_id) + screenshot_dir.mkdir(parents=True, exist_ok=True) + + dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz) + filename = dt.strftime("%Y-%m-%d_%H-%M-%S.jpg") + filepath = screenshot_dir / filename + + # Получаем скриншот через существующий метод + screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp) + + if screenshot_data: + filepath.write_bytes(screenshot_data) + _LOGGER.info(f"✅ Скриншот сохранён: {filepath} ({len(screenshot_data)} байт)") + self.cleanup_old_screenshots(intercom_id) + return str(filepath) + + return None + + def cleanup_old_archives(self, intercom_id: str): + """Удаляем старые архивные видео, если превышено количество копий.""" + archive_dir = self._get_archive_dir(intercom_id) + if not archive_dir.exists(): + return + + files = sorted(archive_dir.glob("*.mp4"), key=lambda f: f.stat().st_mtime) + while len(files) > self.archive_copies: + oldest = files.pop(0) + oldest.unlink() + _LOGGER.debug(f"🗑️ Удалён старый архив: {oldest.name}") + + def cleanup_old_screenshots(self, intercom_id: str): + """Удаляем старые скриншоты, если превышено количество копий.""" + screenshot_dir = self._get_screenshot_dir(intercom_id) + if not screenshot_dir.exists(): + return + + files = sorted(screenshot_dir.glob("*.jpg"), key=lambda f: f.stat().st_mtime) + while len(files) > self.screenshot_copies: + oldest = files.pop(0) + oldest.unlink() + _LOGGER.debug(f"🗑️ Удалён старый скриншот: {oldest.name}") + 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) @@ -214,12 +359,12 @@ class RTKeyCamerasApi: 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( @@ -233,10 +378,10 @@ class RTKeyCamerasApi: 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: @@ -255,7 +400,7 @@ class RTKeyCamerasApi: "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)} устройств") @@ -265,17 +410,17 @@ class RTKeyCamerasApi: except Exception as e: _LOGGER.error(f"Ошибка получения камер: {e}") return [] - + async def refresh_tokens(self): """🔄 Принудительно обновляем все токены (сбрасываем кэш).""" async with self._tokens_refresh_lock: - _LOGGER.info("🔄 Принудительное обновление токенов...") + _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: @@ -322,10 +467,10 @@ class RTKeyCamerasApi: 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({ @@ -338,18 +483,16 @@ class RTKeyCamerasApi: "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: @@ -357,19 +500,18 @@ class RTKeyCamerasApi: 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( @@ -385,8 +527,7 @@ class RTKeyCamerasApi: timeout=15, ) ) - - # ✅ Обработка 403 — токен протух + if r.status_code == 403 and not retry: _LOGGER.warning( f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..." @@ -400,8 +541,7 @@ class RTKeyCamerasApi: f"❌ Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией" ) return None - - # ✅ Обработка 404 — камера недоступна + if r.status_code == 404: if camera_id not in self._unavailable_cameras: _LOGGER.warning( @@ -409,13 +549,12 @@ class RTKeyCamerasApi: ) 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}) снова доступна") @@ -429,8 +568,7 @@ class RTKeyCamerasApi: 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) @@ -481,17 +619,17 @@ class RTKeyCamerasApi: 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( @@ -591,13 +729,25 @@ class RTKeyCamerasApi: if not event_timestamp: return - screenshot_data = None - if self.event_screenshot_enabled: - screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp) + # 🆕 Скачиваем скриншот на диск (если включено) + screenshot_path = None + screenshot_error = None + if self.screenshot_copies > 0: + try: + screenshot_path = await self.download_event_screenshot(camera_id, event_timestamp, intercom_id) + except Exception as e: + screenshot_error = str(e) + _LOGGER.error(f"Ошибка скачивания скриншота для {intercom_id}: {e}") - archive_url = None - if self.event_archive_enabled: - archive_url = self.get_event_archive_url(camera_id, event_timestamp) + # 🆕 Скачиваем архив на диск (если включено) + archive_path = None + archive_error = None + if self.archive_copies > 0: + try: + archive_path = await self.download_event_archive(camera_id, event_timestamp, intercom_id) + except Exception as e: + archive_error = str(e) + _LOGGER.error(f"Ошибка скачивания архива для {intercom_id}: {e}") self.last_events[intercom_id] = { "event": event, @@ -606,8 +756,10 @@ class RTKeyCamerasApi: "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, + "screenshot_path": screenshot_path, + "archive_path": archive_path, + "screenshot_error": screenshot_error, + "archive_error": archive_error, } _LOGGER.info( @@ -667,7 +819,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "cameras_api": cameras_api } - + + # 🆕 Инициализация (проверка FFmpeg) + await cameras_api.async_initialize() + entry.add_update_listener(update_listener) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -679,7 +834,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) diff --git a/custom_components/rtkey/camera.py b/custom_components/rtkey/camera.py index 79053b6..6b47222 100644 --- a/custom_components/rtkey/camera.py +++ b/custom_components/rtkey/camera.py @@ -1,13 +1,12 @@ import datetime +from pathlib import Path 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 @@ -18,21 +17,21 @@ async def async_setup_entry( ) -> 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: + # 🆕 Добавляем архивные камеры только если archive_copies > 0 + if cameras_api.archive_copies > 0: 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("Архивные видео событий отключены") + _LOGGER.info("Архивные видео событий отключены (archive_copies=0)") async_add_entities(entities) @@ -86,7 +85,6 @@ class RTKeyCamera(Camera): 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() @@ -108,12 +106,10 @@ class RTKeyCamera(Camera): @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: @@ -145,7 +141,7 @@ class RTKeyCamera(Camera): class RTKeyEventCamera(Camera): - """Камера для просмотра архивного видео на момент последнего события.""" + """Камера для просмотра архивного видео на момент последнего события (локальный файл).""" _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -176,7 +172,8 @@ class RTKeyEventCamera(Camera): self._event_description = None self._event_time = None - self._archive_url = None + self._archive_path = None + self._archive_error = None async def async_added_to_hass(self) -> None: await super().async_added_to_hass() @@ -199,20 +196,35 @@ class RTKeyEventCamera(Camera): if not event_info: return - archive_url = event_info.get("archive_url") - if archive_url: - self._archive_url = archive_url + archive_path = event_info.get("archive_path") + archive_error = event_info.get("archive_error") + + if archive_path: + self._archive_path = archive_path + self._archive_error = None 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) + _LOGGER.debug(f"Обновляем путь архива для {self.device_name}") + # Для локального файла stream не обновляем через update_source + # stream_source() будет вызван при запросе self.async_write_ha_state() + elif archive_error: + self._archive_error = archive_error + self.async_write_ha_state() async def stream_source(self) -> str | None: - return self._archive_url + """Возвращаем путь к локальному файлу.""" + if self._archive_path and Path(self._archive_path).exists(): + return f"file://{self._archive_path}" + return None + + @property + def available(self) -> bool: + """Доступна, если есть локальный файл.""" + return self._archive_path is not None and Path(self._archive_path).exists() @property def extra_state_attributes(self) -> dict[str, Any]: @@ -221,8 +233,10 @@ class RTKeyEventCamera(Camera): attrs["Событие"] = self._event_description if self._event_time: attrs["Время"] = self._event_time - if self._archive_url: - attrs["URL архива"] = self._archive_url + if self._archive_path: + attrs["Путь к архиву"] = self._archive_path + if self._archive_error: + attrs["Ошибка скачивания"] = self._archive_error return attrs @property diff --git a/custom_components/rtkey/config_flow.py b/custom_components/rtkey/config_flow.py index 020b862..1d70f19 100644 --- a/custom_components/rtkey/config_flow.py +++ b/custom_components/rtkey/config_flow.py @@ -5,7 +5,6 @@ from homeassistant.config_entries import ( OptionsFlowWithConfigEntry, ) from homeassistant.core import callback - from . import ( CONF_NAME, CONF_TOKEN, @@ -16,11 +15,11 @@ from . import ( 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, + CONF_ARCHIVE_PATH, + CONF_ARCHIVE_COPIES, + CONF_SCREENSHOT_COPIES, DATA_SCHEMA, OPTIONS_SCHEMA, DOMAIN, @@ -64,11 +63,10 @@ class RTKeyConfigFlow(ConfigFlow, domain=DOMAIN): 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/image.py b/custom_components/rtkey/image.py index d9f90a9..c1099ed 100644 --- a/custom_components/rtkey/image.py +++ b/custom_components/rtkey/image.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta +from pathlib import Path from typing import Any - from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -8,7 +8,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.entity import EntityCategory from zoneinfo import ZoneInfo - from . import CONF_CAMERA_IMAGE_REFRESH_INTERVAL, DOMAIN, RTKeyCamerasApi, _LOGGER @@ -19,8 +18,7 @@ async def async_setup_entry( ) -> None: cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"] cameras_info = await cameras_api.get_cameras_info() - - # Проверяем, включены ли обычные скриншоты + interval = config_entry.options.get(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, 60) entities = [] @@ -34,15 +32,15 @@ async def async_setup_entry( else: _LOGGER.info("Обычные скриншоты отключены (interval=0)") - # Скриншоты событий - if cameras_api.event_screenshot_enabled: + # 🆕 Скриншоты событий только если screenshot_copies > 0 + if cameras_api.screenshot_copies > 0: intercoms_info = await cameras_api.get_intercoms_info() for intercom_info in intercoms_info.get("data", {}).get("devices", []): entities.append( RTKeyEventImage(hass, config_entry, cameras_api, intercom_info) ) else: - _LOGGER.info("Скриншоты событий отключены") + _LOGGER.info("Скриншоты событий отключены (screenshot_copies=0)") async_add_entities(entities) @@ -97,8 +95,6 @@ class RTKeyCameraImageEntity(ImageEntity): await super().async_will_remove_from_hass() async def _async_update_image(self, now: datetime | None = None) -> None: - """Продолжаем опрашивать API даже если камера недоступна, - чтобы узнать, когда она снова появится.""" try: image = await self.cameras_api.get_camera_image(self.camera_id) if image: @@ -114,12 +110,10 @@ class RTKeyCameraImageEntity(ImageEntity): @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: @@ -147,7 +141,7 @@ class RTKeyCameraImageEntity(ImageEntity): class RTKeyEventImage(ImageEntity): - """Скриншот на момент последнего события домофона.""" + """Скриншот на момент последнего события домофона (локальный файл).""" _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -176,7 +170,8 @@ class RTKeyEventImage(ImageEntity): self._attr_name = "Скриншот события" self._attr_image_last_updated = None - self._cached_image: bytes | None = None + self._screenshot_path = None + self._screenshot_error = None self._event_description = None self._event_time = None @@ -201,13 +196,15 @@ class RTKeyEventImage(ImageEntity): if not event_info: return - screenshot_data = event_info.get("screenshot_data") - if screenshot_data: - self._cached_image = screenshot_data + screenshot_path = event_info.get("screenshot_path") + screenshot_error = event_info.get("screenshot_error") + + if screenshot_path: + self._screenshot_path = screenshot_path + self._screenshot_error = None self._event_description = event_info.get("description") self._event_time = event_info.get("local_time") - # ✅ Используем время события, а не текущее время event_timestamp = event_info.get("timestamp") if event_timestamp: self._attr_image_last_updated = datetime.fromtimestamp( @@ -219,9 +216,25 @@ class RTKeyEventImage(ImageEntity): self.async_write_ha_state() _LOGGER.debug(f"Обновлён скриншот события для {self.device_name}") + elif screenshot_error: + self._screenshot_error = screenshot_error + self.async_write_ha_state() async def async_image(self) -> bytes | None: - return self._cached_image + """Читаем скриншот с диска.""" + if self._screenshot_path and Path(self._screenshot_path).exists(): + try: + return await self.hass.async_add_executor_job( + Path(self._screenshot_path).read_bytes + ) + except Exception as e: + _LOGGER.error(f"Ошибка чтения скриншота {self._screenshot_path}: {e}") + return None + + @property + def available(self) -> bool: + """Доступна, если есть локальный файл.""" + return self._screenshot_path is not None and Path(self._screenshot_path).exists() @property def extra_state_attributes(self) -> dict[str, Any]: @@ -230,6 +243,10 @@ class RTKeyEventImage(ImageEntity): attrs["Событие"] = self._event_description if self._event_time: attrs["Время"] = self._event_time + if self._screenshot_path: + attrs["Путь к скриншоту"] = self._screenshot_path + if self._screenshot_error: + attrs["Ошибка скачивания"] = self._screenshot_error return attrs @property diff --git a/custom_components/rtkey/translations/en.json b/custom_components/rtkey/translations/en.json index c956649..4b0948d 100644 --- a/custom_components/rtkey/translations/en.json +++ b/custom_components/rtkey/translations/en.json @@ -14,15 +14,18 @@ "events_refresh_interval": "Event polling interval (sec)", "event_time_lag": "Camera time lag (sec)", "pre_event_seconds": "Seconds before event to show", - "event_screenshot_enabled": "Create event screenshot entity", "event_screenshot_quality": "Event screenshot quality", - "event_archive_enabled": "Create event archive camera", - "archive_duration": "Archive duration (sec)", - "event_sensor_enabled": "Create event sensor entity" + "event_sensor_enabled": "Create event sensor entity", + "archive_path": "Archive storage path", + "archive_copies": "Number of video copies (0 = disabled)", + "screenshot_copies": "Number of screenshot copies (0 = disabled)" }, "data_description": { "token": "Get token via browser developer tools (F12) on key.rt.ru", - "camera_image_refresh_interval": "0 — disable periodic screenshots" + "camera_image_refresh_interval": "0 — disable periodic screenshots", + "archive_path": "Relative to /media/ folder (e.g.: RTKey)", + "archive_copies": "Maximum number of saved videos per intercom", + "screenshot_copies": "Maximum number of saved screenshots per intercom" } } } @@ -42,15 +45,18 @@ "events_refresh_interval": "Event polling interval (sec)", "event_time_lag": "Camera time lag (sec)", "pre_event_seconds": "Seconds before event to show", - "event_screenshot_enabled": "Create event screenshot entity", "event_screenshot_quality": "Event screenshot quality", - "event_archive_enabled": "Create event archive camera", - "archive_duration": "Archive duration (sec)", - "event_sensor_enabled": "Create event sensor entity" + "event_sensor_enabled": "Create event sensor entity", + "archive_path": "Archive storage path", + "archive_copies": "Number of video copies (0 = disabled)", + "screenshot_copies": "Number of screenshot copies (0 = disabled)" }, "data_description": { "token": "Get token via browser developer tools (F12) on key.rt.ru", - "camera_image_refresh_interval": "0 — disable periodic screenshots" + "camera_image_refresh_interval": "0 — disable periodic screenshots", + "archive_path": "Relative to /media/ folder (e.g.: RTKey)", + "archive_copies": "Maximum number of saved videos per intercom", + "screenshot_copies": "Maximum number of saved screenshots per intercom" } } } diff --git a/custom_components/rtkey/translations/ru.json b/custom_components/rtkey/translations/ru.json index 1c9366c..77f67ff 100644 --- a/custom_components/rtkey/translations/ru.json +++ b/custom_components/rtkey/translations/ru.json @@ -14,15 +14,18 @@ "events_refresh_interval": "Интервал опроса событий (сек)", "event_time_lag": "Задержка времени камеры (сек)", "pre_event_seconds": "Секунд до события для показа", - "event_screenshot_enabled": "Создавать сущность скриншота события", "event_screenshot_quality": "Качество скриншота события", - "event_archive_enabled": "Создавать камеру архива событий", - "archive_duration": "Длительность архива (сек)", - "event_sensor_enabled": "Создавать сенсор последнего события" + "event_sensor_enabled": "Создавать сенсор последнего события", + "archive_path": "Путь для сохранения архивов", + "archive_copies": "Количество копий видео (0 = отключено)", + "screenshot_copies": "Количество копий скриншотов (0 = отключено)" }, "data_description": { "token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru", - "camera_image_refresh_interval": "0 — отключить периодические скриншоты" + "camera_image_refresh_interval": "0 — отключить периодические скриншоты", + "archive_path": "Относительно папки /media/ (например: RTKey)", + "archive_copies": "Максимальное количество сохранённых видео на домофон", + "screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон" } } } @@ -42,15 +45,18 @@ "events_refresh_interval": "Интервал опроса событий (сек)", "event_time_lag": "Задержка времени камеры (сек)", "pre_event_seconds": "Секунд до события для показа", - "event_screenshot_enabled": "Создавать сущность скриншота события", "event_screenshot_quality": "Качество скриншота события", - "event_archive_enabled": "Создавать камеру архива событий", - "archive_duration": "Длительность архива (сек)", - "event_sensor_enabled": "Создавать сенсор последнего события" + "event_sensor_enabled": "Создавать сенсор последнего события", + "archive_path": "Путь для сохранения архивов", + "archive_copies": "Количество копий видео (0 = отключено)", + "screenshot_copies": "Количество копий скриншотов (0 = отключено)" }, "data_description": { "token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru", - "camera_image_refresh_interval": "0 — отключить периодические скриншоты" + "camera_image_refresh_interval": "0 — отключить периодические скриншоты", + "archive_path": "Относительно папки /media/ (например: RTKey)", + "archive_copies": "Максимальное количество сохранённых видео на домофон", + "screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон" } } }