Оптимизация и модернизация __init__.py

— aiohttp вместо requests: реальный async, единая ClientSession, connection pooling
— async file I/O через executor (write_bytes, mkdir, cleanup)
— asyncio.gather для параллельного опроса событий
— asyncio.wait_for(timeout=5) на FFmpeg check
— CameraTokenInfo dataclass вместо dict
— yarl.URL для безопасной кодировки query-параметров
— Общие _api_get/_api_post, уменьшение дублирования
— CONF_ARCHIVE_DURATION вынесена в настройки
— Удалена бессмысленная ветка в get_intercoms_info
— _close_session() cleanup при unload
— sensor.py: archive_url → archive_path
— Обновлены переводы и config_flow для archive_duration
This commit is contained in:
2026-06-28 17:14:49 +03:00
parent 97624d6750
commit 15a97be8e2
5 changed files with 433 additions and 373 deletions
+286 -235
View File
@@ -1,16 +1,18 @@
import asyncio import asyncio
import functools
import logging import logging
import time import time
from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import requests
import aiohttp
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import selector from homeassistant.helpers import selector
from yarl import URL
DOMAIN = "rtkey" DOMAIN = "rtkey"
PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR] PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR]
@@ -31,9 +33,10 @@ CONF_PRE_EVENT_SECONDS = "pre_event_seconds"
CONF_EVENT_SCREENSHOT_QUALITY = "event_screenshot_quality" CONF_EVENT_SCREENSHOT_QUALITY = "event_screenshot_quality"
CONF_EVENT_SENSOR_ENABLED = "event_sensor_enabled" CONF_EVENT_SENSOR_ENABLED = "event_sensor_enabled"
# Новые настройки для локального хранения # Настройки локального хранения
CONF_ARCHIVE_PATH = "archive_path" CONF_ARCHIVE_PATH = "archive_path"
CONF_ARCHIVE_COPIES = "archive_copies" CONF_ARCHIVE_COPIES = "archive_copies"
CONF_ARCHIVE_DURATION = "archive_duration"
CONF_SCREENSHOT_COPIES = "screenshot_copies" CONF_SCREENSHOT_COPIES = "screenshot_copies"
TOKEN_REFRESH_BUFFER = 300 TOKEN_REFRESH_BUFFER = 300
@@ -108,7 +111,7 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN, mode=selector.SelectSelectorMode.DROPDOWN,
) )
), ),
# 🆕 Настройки локального хранения # Настройки локального хранения
vol.Optional(CONF_ARCHIVE_PATH, default="media/RTKey"): str, vol.Optional(CONF_ARCHIVE_PATH, default="media/RTKey"): str,
vol.Optional(CONF_ARCHIVE_COPIES, default=5): selector.NumberSelector( vol.Optional(CONF_ARCHIVE_COPIES, default=5): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
@@ -116,6 +119,13 @@ OPTIONS_SCHEMA = {
mode=selector.NumberSelectorMode.BOX, mode=selector.NumberSelectorMode.BOX,
) )
), ),
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_SCREENSHOT_COPIES, default=10): selector.NumberSelector( vol.Optional(CONF_SCREENSHOT_COPIES, default=10): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=0, max=10, step=1, min=0, max=10, step=1,
@@ -126,6 +136,31 @@ OPTIONS_SCHEMA = {
} }
@dataclass
class CameraTokenInfo:
title: str = "Устройство"
category_type: str = "unknown"
category_title: str = ""
model: str = ""
vendor: str = ""
serial_number: str = ""
mac: str = ""
ip: str = ""
status_type: str = "unknown"
status_title: str = ""
streamer_token: str | None = None
screenshot_token: str | None = None
user_token: str | None = None
_DEFAULT_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",
}
class RTKeyCamerasApi: class RTKeyCamerasApi:
def __init__(self, hass: HomeAssistant, entry: ConfigEntry): def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
self.hass = hass self.hass = hass
@@ -141,25 +176,28 @@ class RTKeyCamerasApi:
self.event_screenshot_quality = entry.options.get(CONF_EVENT_SCREENSHOT_QUALITY, "large") self.event_screenshot_quality = entry.options.get(CONF_EVENT_SCREENSHOT_QUALITY, "large")
self.event_sensor_enabled = entry.options.get(CONF_EVENT_SENSOR_ENABLED, True) 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_path = entry.options.get(CONF_ARCHIVE_PATH, "media/RTKey")
self.archive_copies = entry.options.get(CONF_ARCHIVE_COPIES, 5) self.archive_copies = entry.options.get(CONF_ARCHIVE_COPIES, 5)
self.archive_duration = entry.options.get(CONF_ARCHIVE_DURATION, 30)
self.screenshot_copies = entry.options.get(CONF_SCREENSHOT_COPIES, 10) self.screenshot_copies = entry.options.get(CONF_SCREENSHOT_COPIES, 10)
log_level = entry.options.get(CONF_LOG_LEVEL, "INFO") log_level = entry.options.get(CONF_LOG_LEVEL, "INFO")
_LOGGER.setLevel(getattr(logging, log_level, logging.INFO)) _LOGGER.setLevel(getattr(logging, log_level, logging.INFO))
_LOGGER.info(f"📊 Уровень логирования: {log_level}") _LOGGER.info(f"Уровень логирования: {log_level}")
self.user_token = None self.user_token: str | None = None
self.lock = asyncio.Lock()
# aiohttp session
self._session: aiohttp.ClientSession | None = None
# Информация о камерах # Информация о камерах
self.camera_tokens = {} self.camera_tokens: dict[str, CameraTokenInfo] = {}
self._cameras_cache = None self._cameras_cache = None
self._cameras_cache_ts = 0 self._cameras_cache_ts = 0
# Кэш картинок # Кэш картинок
self._image_cache = {} self._image_cache: dict[str, tuple[bytes, float]] = {}
self._image_cache_lock = asyncio.Lock() self._image_cache_lock = asyncio.Lock()
# Отслеживание недоступных камер (404) # Отслеживание недоступных камер (404)
@@ -170,61 +208,66 @@ class RTKeyCamerasApi:
self._tokens_refresh_lock = asyncio.Lock() self._tokens_refresh_lock = asyncio.Lock()
# Данные событий # Данные событий
self.last_events = {} self.last_events: dict[str, dict] = {}
self._last_request_ids = {} self._last_request_ids: dict[str, str] = {}
self.rfid_names = {} self.rfid_names: dict[str, str] = {}
self.intercom_camera_map = {} self.intercom_camera_map: dict[str, str] = {}
self.intercom_ids = [] self.intercom_ids: list[str] = []
self._events_polling_task: asyncio.Task | None = None self._events_polling_task: asyncio.Task | None = None
self._event_listeners = [] self._event_listeners: list = []
self._local_tz = ZoneInfo(hass.config.time_zone) self._local_tz = ZoneInfo(hass.config.time_zone)
_LOGGER.info(f"🕐 Временная зона HA: {hass.config.time_zone}") _LOGGER.info(f"Временная зона HA: {hass.config.time_zone}")
# 🆕 Проверка FFmpeg
self.ffmpeg_available = False self.ffmpeg_available = False
def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def async_initialize(self): async def async_initialize(self):
"""Инициализация после создания (для async операций)."""
await self._check_ffmpeg() await self._check_ffmpeg()
async def _check_ffmpeg(self): async def _check_ffmpeg(self):
"""Проверяем наличие FFmpeg в системе."""
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-version", "ffmpeg", "-version",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
await proc.communicate() await asyncio.wait_for(proc.communicate(), timeout=5)
self.ffmpeg_available = True self.ffmpeg_available = True
_LOGGER.info("✅ FFmpeg доступен") _LOGGER.info("FFmpeg доступен")
except FileNotFoundError: except FileNotFoundError:
self.ffmpeg_available = False self.ffmpeg_available = False
_LOGGER.error("❌ FFmpeg не найден! Архивное видео будет недоступно.") _LOGGER.error("FFmpeg не найден! Архивное видео будет недоступно.")
except Exception as e: except (asyncio.TimeoutError, Exception) as e:
self.ffmpeg_available = False self.ffmpeg_available = False
_LOGGER.error(f"❌ Ошибка проверки FFmpeg: {e}") _LOGGER.error(f"Ошибка проверки FFmpeg: {e}")
async def _close_session(self):
if self._session and not self._session.closed:
await self._session.close()
self._session = None
# === Медиа-пути ===
def _get_media_base_path(self) -> Path: def _get_media_base_path(self) -> Path:
"""Получаем базовый путь для медиафайлов."""
# HA хранит медиа в /media/ (media_dirs)
media_dirs = self.hass.config.media_dirs media_dirs = self.hass.config.media_dirs
if media_dirs and "media" in media_dirs: if media_dirs and "media" in media_dirs:
return Path(media_dirs["media"]) return Path(media_dirs["media"])
# Fallback
return Path("/media") return Path("/media")
def _get_archive_dir(self, intercom_id: str) -> Path: def _get_archive_dir(self, intercom_id: str) -> Path:
"""Путь к папке архивов для домофона."""
return self._get_media_base_path() / self.archive_path / "archives" / intercom_id return self._get_media_base_path() / self.archive_path / "archives" / intercom_id
def _get_screenshot_dir(self, intercom_id: str) -> Path: def _get_screenshot_dir(self, intercom_id: str) -> Path:
"""Путь к папке скриншотов для домофона."""
return self._get_media_base_path() / self.archive_path / "screenshots" / intercom_id return self._get_media_base_path() / self.archive_path / "screenshots" / intercom_id
# === Скачивание и cleanup ===
async def download_event_archive(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None: async def download_event_archive(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None:
"""Скачиваем архивное видео через FFmpeg и сохраняем локально."""
if not self.ffmpeg_available: if not self.ffmpeg_available:
_LOGGER.error("FFmpeg недоступен, пропускаем скачивание архива") _LOGGER.error("FFmpeg недоступен, пропускаем скачивание архива")
return None return None
@@ -234,19 +277,14 @@ class RTKeyCamerasApi:
_LOGGER.error(f"Не удалось получить URL архива для камеры {camera_id}") _LOGGER.error(f"Не удалось получить URL архива для камеры {camera_id}")
return None return None
# Создаём папку
archive_dir = self._get_archive_dir(intercom_id) archive_dir = self._get_archive_dir(intercom_id)
archive_dir.mkdir(parents=True, exist_ok=True) await self.hass.async_add_executor_job(archive_dir.mkdir, True, True)
# Имя файла
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz) dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.mp4") filename = dt.strftime("%Y-%m-%d_%H-%M-%S.mp4")
filepath = archive_dir / filename filepath = archive_dir / filename
# Вычисляем длительность: pre_event + lag + post_event (archive_duration) duration = self.pre_event_seconds + self.event_time_lag + self.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}") _LOGGER.info(f"Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}")
@@ -258,6 +296,7 @@ class RTKeyCamerasApi:
str(filepath), str(filepath),
] ]
proc: asyncio.subprocess.Process | None = None
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd,
@@ -266,20 +305,26 @@ class RTKeyCamerasApi:
) )
stdout, stderr = await asyncio.wait_for( stdout, stderr = await asyncio.wait_for(
proc.communicate(), proc.communicate(),
timeout=duration + 30 # Запас на задержки сети timeout=duration + 30,
) )
if proc.returncode == 0 and filepath.exists() and filepath.stat().st_size > 1000: if proc.returncode == 0:
_LOGGER.info(f"✅ Архив сохранён: {filepath} ({filepath.stat().st_size} байт)") size = await self.hass.async_add_executor_job(
self.cleanup_old_archives(intercom_id) lambda p: p.stat().st_size if p.exists() else 0, filepath
)
if size > 1000:
_LOGGER.info(f"Архив сохранён: {filepath} ({size} байт)")
await self.cleanup_old_files(
self._get_archive_dir(intercom_id), "*.mp4", self.archive_copies
)
return str(filepath) return str(filepath)
else:
error_msg = stderr.decode()[:200] if stderr else "Неизвестная ошибка" error_msg = stderr.decode()[:200] if stderr else "Неизвестная ошибка"
_LOGGER.error(f"❌ Ошибка FFmpeg для {intercom_id}: {error_msg}") _LOGGER.error(f"Ошибка FFmpeg для {intercom_id}: {error_msg}")
return None return None
except asyncio.TimeoutError: except asyncio.TimeoutError:
_LOGGER.error(f"⏱️ Превышено время скачивания архива для {intercom_id}") _LOGGER.error(f"Превышено время скачивания архива для {intercom_id}")
if proc: if proc:
proc.kill() proc.kill()
return None return None
@@ -288,61 +333,48 @@ class RTKeyCamerasApi:
return None return None
async def download_event_screenshot(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | 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 = self._get_screenshot_dir(intercom_id)
screenshot_dir.mkdir(parents=True, exist_ok=True) await self.hass.async_add_executor_job(screenshot_dir.mkdir, True, True)
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz) dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.jpg") filename = dt.strftime("%Y-%m-%d_%H-%M-%S.jpg")
filepath = screenshot_dir / filename filepath = screenshot_dir / filename
# Получаем скриншот через существующий метод
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp) screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp)
if screenshot_data: if screenshot_data:
filepath.write_bytes(screenshot_data) await self.hass.async_add_executor_job(filepath.write_bytes, screenshot_data)
_LOGGER.info(f"✅ Скриншот сохранён: {filepath} ({len(screenshot_data)} байт)") _LOGGER.info(f"Скриншот сохранён: {filepath} ({len(screenshot_data)} байт)")
self.cleanup_old_screenshots(intercom_id) await self.cleanup_old_files(
self._get_screenshot_dir(intercom_id), "*.jpg", self.screenshot_copies
)
return str(filepath) return str(filepath)
return None return None
def cleanup_old_archives(self, intercom_id: str): async def cleanup_old_files(self, directory: Path, pattern: str, max_copies: int):
"""Удаляем старые архивные видео, если превышено количество копий.""" """Удаляем старые файлы, если превышено количество копий (async via executor)."""
archive_dir = self._get_archive_dir(intercom_id) def _cleanup():
if not archive_dir.exists(): if not directory.exists():
return return
files = sorted(directory.glob(pattern), key=lambda f: f.stat().st_mtime)
files = sorted(archive_dir.glob("*.mp4"), key=lambda f: f.stat().st_mtime) while len(files) > max_copies:
while len(files) > self.archive_copies:
oldest = files.pop(0) oldest = files.pop(0)
oldest.unlink() oldest.unlink()
_LOGGER.debug(f"🗑️ Удалён старый архив: {oldest.name}") _LOGGER.debug(f"Удалён старый файл: {oldest.name}")
def cleanup_old_screenshots(self, intercom_id: str): await self.hass.async_add_executor_job(_cleanup)
"""Удаляем старые скриншоты, если превышено количество копий."""
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: def build_device_name(self, name: str) -> str:
return name.strip() if name else "RT Key Device" return name.strip() if name else "RT Key Device"
def get_camera_name(self, camera_id: str) -> str: def get_camera_name(self, camera_id: str) -> str:
"""Получаем название камеры по её ID.""" info = self.camera_tokens.get(camera_id)
token_info = self.camera_tokens.get(camera_id) return info.title if info else "Неизвестная камера"
if token_info:
return token_info.get("title", "Неизвестная камера")
return "Неизвестная камера"
def is_camera_available(self, camera_id: str) -> bool: def is_camera_available(self, camera_id: str) -> bool:
"""Проверяем, доступна ли камера (не в списке недоступных)."""
return camera_id not in self._unavailable_cameras return camera_id not in self._unavailable_cameras
def register_event_listener(self, callback): def register_event_listener(self, callback):
@@ -360,23 +392,53 @@ class RTKeyCamerasApi:
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка в event listener: {e}") _LOGGER.error(f"Ошибка в event listener: {e}")
# === HTTP API (aiohttp) ===
async def _api_get(self, url: str | URL, headers: dict, timeout: int = 20) -> aiohttp.ClientResponse | None:
session = self._get_session()
try:
async with session.get(
url,
headers=headers,
timeout=aiohttp.ClientTimeout(total=timeout),
) as r:
return r
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
_LOGGER.error(f"HTTP ошибка GET {url}: {e}")
return None
async def _api_post(self, url: str | URL, headers: dict, json: dict | None = None, timeout: int = 10) -> aiohttp.ClientResponse | None:
session = self._get_session()
try:
async with session.post(
url,
headers=headers,
json=json or {},
timeout=aiohttp.ClientTimeout(total=timeout),
) as r:
return r
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
_LOGGER.error(f"HTTP ошибка POST {url}: {e}")
return None
# === Камеры ===
async def _fetch_cameras(self): async def _fetch_cameras(self):
"""Получаем информацию о камерах (токены для стрима и скриншотов)."""
if self._cameras_cache and (time.time() - self._cameras_cache_ts < 60): if self._cameras_cache and (time.time() - self._cameras_cache_ts < 60):
return self._cameras_cache return self._cameras_cache
try: try:
_LOGGER.debug("Запрос camera_video_data/list...") _LOGGER.debug("Запрос camera_video_data/list...")
r = await self.hass.async_add_executor_job( url = URL("https://keyapis.key.rt.ru/vc/api/v1/camera_video_data/list").with_query({
functools.partial( "paging.limit": 100,
requests.get, "paging.offset": 0,
"https://keyapis.key.rt.ru/vc/api/v1/camera_video_data/list?paging.limit=100&paging.offset=0", })
headers={"Authorization": f"Bearer {self.token}"}, r = await self._api_get(url, {"Authorization": f"Bearer {self.token}"})
timeout=20, if r is None:
) return []
)
if r.status_code == 200: if r.status == 200:
data = r.json() data = await r.json()
devices = data.get("data", []) devices = data.get("data", [])
if devices: if devices:
@@ -385,66 +447,65 @@ class RTKeyCamerasApi:
for device in devices: for device in devices:
uid = device.get("uid") uid = device.get("uid")
if uid: if uid:
self.camera_tokens[uid] = { self.camera_tokens[uid] = CameraTokenInfo(
"title": device.get("title", "Устройство"), title=device.get("title", "Устройство"),
"category_type": device.get("category", {}).get("type", "unknown"), category_type=device.get("category", {}).get("type", "unknown"),
"category_title": device.get("category", {}).get("title", ""), category_title=device.get("category", {}).get("title", ""),
"model": device.get("model", ""), model=device.get("model", ""),
"vendor": device.get("vendor", ""), vendor=device.get("vendor", ""),
"serial_number": device.get("serialNumber", ""), serial_number=device.get("serialNumber", ""),
"mac": device.get("mac", ""), mac=device.get("mac", ""),
"ip": device.get("ip", ""), ip=device.get("ip", ""),
"status_type": device.get("status", {}).get("type", "unknown"), status_type=device.get("status", {}).get("type", "unknown"),
"status_title": device.get("status", {}).get("title", ""), status_title=device.get("status", {}).get("title", ""),
"streamer_token": device.get("streamerToken"), streamer_token=device.get("streamerToken"),
"screenshot_token": device.get("screenshotToken"), screenshot_token=device.get("screenshotToken"),
"user_token": device.get("userToken"), user_token=device.get("userToken"),
} )
self._cameras_cache = devices self._cameras_cache = devices
self._cameras_cache_ts = time.time() self._cameras_cache_ts = time.time()
_LOGGER.info(f"✅ Загружено {len(self.camera_tokens)} устройств") _LOGGER.info(f"Загружено {len(self.camera_tokens)} устройств")
return devices return devices
else: else:
_LOGGER.error(f"camera_video_data/list вернул {r.status_code}") _LOGGER.error(f"camera_video_data/list вернул {r.status}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка получения камер: {e}") _LOGGER.error(f"Ошибка получения камер: {e}")
return [] return []
async def refresh_tokens(self): async def refresh_tokens(self):
"""🔄 Принудительно обновляем все токены (сбрасываем кэш)."""
async with self._tokens_refresh_lock: async with self._tokens_refresh_lock:
_LOGGER.info("Принудительное обновление токенов...") _LOGGER.info("Принудительное обновление токенов...")
self._cameras_cache = None self._cameras_cache = None
self._cameras_cache_ts = 0 self._cameras_cache_ts = 0
await self._fetch_cameras() await self._fetch_cameras()
self._tokens_invalid = False self._tokens_invalid = False
_LOGGER.info(f"✅ Токены обновлены для {len(self.camera_tokens)} устройств") _LOGGER.info(f"Токены обновлены для {len(self.camera_tokens)} устройств")
# === Домофоны ===
async def _fetch_intercoms(self): async def _fetch_intercoms(self):
"""Получаем информацию о домофонах из отдельного API."""
try: try:
_LOGGER.debug("Запрос домофонов из household.key.rt.ru...") _LOGGER.debug("Запрос домофонов из household.key.rt.ru...")
r = await self.hass.async_add_executor_job( r = await self._api_get(
functools.partial(
requests.get,
"https://household.key.rt.ru/api/v2/app/devices/intercom", "https://household.key.rt.ru/api/v2/app/devices/intercom",
headers={"Authorization": f"Bearer {self.token}"}, {"Authorization": f"Bearer {self.token}"},
timeout=15, timeout=15,
) )
) if r is None:
if r.status_code == 200: return {"data": {"devices": []}}
data = r.json()
if r.status == 200:
data = await r.json()
self._build_intercom_mappings(data) self._build_intercom_mappings(data)
devices = data.get("data", {}).get("devices", []) devices = data.get("data", {}).get("devices", [])
self.intercom_ids = [str(d.get("id")) for d in devices if d.get("id")] 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}") _LOGGER.info(f"Загружено {len(self.intercom_ids)} домофонов: {self.intercom_ids}")
return data return data
else: else:
_LOGGER.error(f"intercom вернул {r.status_code}") _LOGGER.error(f"intercom вернул {r.status}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка получения домофонов: {e}") _LOGGER.error(f"Ошибка получения домофонов: {e}")
return {"data": {"devices": []}} return {"data": {"devices": []}}
@@ -475,176 +536,168 @@ class RTKeyCamerasApi:
for uid, info in self.camera_tokens.items(): for uid, info in self.camera_tokens.items():
items.append({ items.append({
"id": uid, "id": uid,
"title": info["title"], "title": info.title,
"category_type": info["category_type"], "category_type": info.category_type,
"status_title": info["status_title"], "status_title": info.status_title,
"model": info["model"], "model": info.model,
"vendor": info["vendor"], "vendor": info.vendor,
"serial_number": info["serial_number"], "serial_number": info.serial_number,
"mac": info["mac"], "mac": info.mac,
"ip": info["ip"], "ip": info.ip,
"available": self.is_camera_available(uid), "available": self.is_camera_available(uid),
}) })
return {"data": {"items": items}} return {"data": {"items": items}}
async def get_intercoms_info(self): async def get_intercoms_info(self):
if not self.intercom_ids:
return await self._fetch_intercoms()
return await self._fetch_intercoms() return await self._fetch_intercoms()
# === Скриншоты ===
async def get_camera_image(self, camera_id: str, retry: bool = False) -> bytes | None: async def get_camera_image(self, camera_id: str, retry: bool = False) -> bytes | None:
"""Получаем скриншот камеры с обработкой ошибок 403 и 404."""
async with self._image_cache_lock: async with self._image_cache_lock:
if camera_id in self._image_cache: if camera_id in self._image_cache:
img, ts = self._image_cache[camera_id] img, ts = self._image_cache[camera_id]
if time.time() - ts < 4: if time.time() - ts < 4:
return img return img
token_info = self.camera_tokens.get(camera_id) info = self.camera_tokens.get(camera_id)
screenshot_token = token_info.get("screenshot_token") if token_info else None screenshot_token = info.screenshot_token if info else None
user_token = (token_info.get("user_token") or self.user_token) if token_info else None user_token = (info.user_token or self.user_token) if info else None
camera_name = self.get_camera_name(camera_id) camera_name = self.get_camera_name(camera_id)
try: try:
url = f"https://key.rt.ru/screenshot/image/{self.screenshot_quality}/{camera_id}/last.jpg" url = URL(f"https://key.rt.ru/screenshot/image/{self.screenshot_quality}/{camera_id}/last.jpg")
if screenshot_token: if screenshot_token:
url += f"?token={screenshot_token}&_={int(time.time() * 1000)}" url = url.with_query({"token": screenshot_token, "_": str(int(time.time() * 1000))})
_LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id}): {url}") _LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id})")
r = await self.hass.async_add_executor_job( r = await self._api_get(
functools.partial(
requests.get,
url, url,
headers={ {**_DEFAULT_HEADERS, "X-UTOKEN": user_token or self.token},
"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, timeout=15,
) )
) if r is None:
return None
if r.status_code == 403 and not retry: if r.status == 403 and not retry:
_LOGGER.warning( _LOGGER.warning(f"Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление...")
f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..."
)
self._tokens_invalid = True self._tokens_invalid = True
await self.refresh_tokens() await self.refresh_tokens()
return await self.get_camera_image(camera_id, retry=True) return await self.get_camera_image(camera_id, retry=True)
if r.status_code == 403 and retry: if r.status == 403 and retry:
_LOGGER.error( _LOGGER.error(f"Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией")
f"❌ Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией"
)
return None return None
if r.status_code == 404: if r.status == 404:
if camera_id not in self._unavailable_cameras: if camera_id not in self._unavailable_cameras:
_LOGGER.warning( _LOGGER.warning(f"Камера \"{camera_name}\" ({camera_id}) недоступна (404), помечаем как offline")
f"📷 Камера \"{camera_name}\" ({camera_id}) недоступна (404), помечаем как offline"
)
self._unavailable_cameras.add(camera_id) self._unavailable_cameras.add(camera_id)
return None return None
if r.status_code == 200 and len(r.content) > 2000: if r.status == 200:
image_data = r.content image_data = await r.read()
if len(image_data) > 2000:
async with self._image_cache_lock: async with self._image_cache_lock:
self._image_cache[camera_id] = (image_data, time.time()) self._image_cache[camera_id] = (image_data, time.time())
if camera_id in self._unavailable_cameras: if camera_id in self._unavailable_cameras:
self._unavailable_cameras.discard(camera_id) self._unavailable_cameras.discard(camera_id)
_LOGGER.info(f"✅ Камера \"{camera_name}\" ({camera_id}) снова доступна") _LOGGER.info(f"Камера \"{camera_name}\" ({camera_id}) снова доступна")
_LOGGER.info(f"✅ Скриншот получен {camera_name} ({camera_id}) — {len(image_data)} байт") _LOGGER.info(f"Скриншот получен {camera_name} ({camera_id}) — {len(image_data)} байт")
return image_data return image_data
else: else:
_LOGGER.warning( _LOGGER.warning(f"Скриншот для {camera_name} ({camera_id}): размер {len(image_data)} слишком мал")
f"Скриншот для {camera_name} ({camera_id}): статус {r.status_code}, размер {len(r.content)}" else:
) _LOGGER.warning(f"Скриншот для {camera_name} ({camera_id}): статус {r.status}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка скриншота {camera_name} ({camera_id}): {e}") _LOGGER.error(f"Ошибка скриншота {camera_name} ({camera_id}): {e}")
return None return None
async def get_event_screenshot(self, camera_id: str, event_timestamp: int) -> bytes | None: async def get_event_screenshot(self, camera_id: str, event_timestamp: int) -> bytes | None:
"""Получаем скриншот на момент события (с учётом лага).""" info = self.camera_tokens.get(camera_id)
token_info = self.camera_tokens.get(camera_id) screenshot_token = info.screenshot_token if info else None
screenshot_token = token_info.get("screenshot_token") if token_info else None user_token = (info.user_token or self.user_token) if 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 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" url = URL(f"https://key.rt.ru/screenshot/image/{self.event_screenshot_quality}/{camera_id}/{adjusted_timestamp}.jpg")
if screenshot_token: if screenshot_token:
url += f"?token={screenshot_token}" url = url.with_query({"token": screenshot_token})
try: try:
_LOGGER.debug(f"Запрос скриншота события для {camera_id}: {url}") _LOGGER.debug(f"Запрос скриншота события для {camera_id}")
r = await self.hass.async_add_executor_job( r = await self._api_get(
functools.partial(
requests.get,
url, url,
headers={ {**_DEFAULT_HEADERS, "X-UTOKEN": user_token or self.token},
"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, timeout=15,
) )
) if r is None:
return None
if r.status_code == 200 and len(r.content) > 1000: if r.status == 200:
_LOGGER.info(f"✅ Скриншот события получен {camera_id} ({len(r.content)} байт)") content = await r.read()
return r.content if len(content) > 1000:
_LOGGER.info(f"Скриншот события получен {camera_id} ({len(content)} байт)")
return content
else: else:
_LOGGER.warning(f"Скриншот события {camera_id}: статус {r.status_code}") _LOGGER.warning(f"Скриншот события {camera_id}: размер {len(content)} слишком мал")
else:
_LOGGER.warning(f"Скриншот события {camera_id}: статус {r.status}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка скриншота события {camera_id}: {e}") _LOGGER.error(f"Ошибка скриншота события {camera_id}: {e}")
return None return None
# === Стримы и архивы ===
def get_event_archive_url(self, camera_id: str, event_timestamp: int) -> str | None: def get_event_archive_url(self, camera_id: str, event_timestamp: int) -> str | None:
token_info = self.camera_tokens.get(camera_id) info = self.camera_tokens.get(camera_id)
streamer_token = token_info.get("streamer_token") if token_info else None streamer_token = info.streamer_token if info else None
adjusted_timestamp = event_timestamp - self.event_time_lag - self.pre_event_seconds 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" url = URL(f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/{adjusted_timestamp}.mp4").with_query({
params = "mp4-fragment-length=0.5&mp4-use-speed=0&mp4-afiller=1" "mp4-fragment-length": "0.5",
"mp4-use-speed": "0",
"mp4-afiller": "1",
})
if streamer_token: if streamer_token:
return f"{base}?{params}&token={streamer_token}" url = url.update_query({"token": streamer_token})
return f"{base}?{params}" return str(url)
async def get_camera_stream_url(self, camera_id: str) -> str | None: async def get_camera_stream_url(self, camera_id: str) -> str | None:
token_info = self.camera_tokens.get(camera_id) info = self.camera_tokens.get(camera_id)
streamer_token = token_info.get("streamer_token") if token_info else None streamer_token = info.streamer_token if info else None
base = f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/live.mp4" url = URL(f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/live.mp4").with_query({
params = "mp4-fragment-length=0.5&mp4-use-speed=0&mp4-afiller=1" "mp4-fragment-length": "0.5",
"mp4-use-speed": "0",
"mp4-afiller": "1",
})
if streamer_token: if streamer_token:
return f"{base}?{params}&token={streamer_token}" url = url.update_query({"token": streamer_token})
return f"{base}?{params}" return str(url)
# === Домофон: открытие ===
async def open_intercom(self, intercom_id: str): async def open_intercom(self, intercom_id: str):
try: try:
await self.hass.async_add_executor_job( r = await self._api_post(
functools.partial(
requests.post,
f"https://household.key.rt.ru/api/v2/app/devices/{intercom_id}/open", f"https://household.key.rt.ru/api/v2/app/devices/{intercom_id}/open",
headers={"Authorization": f"Bearer {self.token}"}, {"Authorization": f"Bearer {self.token}"},
json={},
timeout=10,
) )
) if r and r.status in (200, 204):
_LOGGER.info(f"✅ Открыт домофон {intercom_id}") _LOGGER.info(f"Открыт домофон {intercom_id}")
elif r:
_LOGGER.error(f"Ошибка открытия домофона {intercom_id}: статус {r.status}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка открытия {intercom_id}: {e}") _LOGGER.error(f"Ошибка открытия {intercom_id}: {e}")
# === Время и события ===
def _parse_iso_to_timestamp(self, iso_str: str) -> int: def _parse_iso_to_timestamp(self, iso_str: str) -> int:
try: try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
@@ -686,35 +739,30 @@ class RTKeyCamerasApi:
async def get_last_event(self, intercom_id: str) -> dict | None: async def get_last_event(self, intercom_id: str) -> dict | None:
try: try:
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
url = ( url = URL("https://events.key.rt.ru/api/v2/events/list").with_query({
f"https://events.key.rt.ru/api/v2/events/list?" "begin_raised_at": "2018-01-01T00:00:00Z",
f"begin_raised_at=2018-01-01T00:00:00Z&" "end_raised_at": now_iso,
f"end_raised_at={now_iso}&" "device_ids": intercom_id,
f"device_ids={intercom_id}&" "limit": "1",
f"limit=1&" "sort_by": "raised_at",
f"sort_by=raised_at&" "offset": "0",
f"offset=0" })
)
r = await self.hass.async_add_executor_job( r = await self._api_get(
functools.partial(
requests.get,
url, url,
headers={ {"Authorization": f"Bearer {self.token}", "Accept": "application/json"},
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
},
timeout=15, timeout=15,
) )
) if r is None:
return None
if r.status_code == 200: if r.status == 200:
data = r.json() data = await r.json()
items = data.get("data", {}).get("items", []) items = data.get("data", {}).get("items", [])
if items: if items:
return items[0] return items[0]
else: else:
_LOGGER.warning(f"Events API вернул {r.status_code} для {intercom_id}") _LOGGER.warning(f"Events API вернул {r.status} для {intercom_id}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка получения событий для {intercom_id}: {e}") _LOGGER.error(f"Ошибка получения событий для {intercom_id}: {e}")
return None return None
@@ -729,7 +777,6 @@ class RTKeyCamerasApi:
if not event_timestamp: if not event_timestamp:
return return
# 🆕 Скачиваем скриншот на диск (если включено)
screenshot_path = None screenshot_path = None
screenshot_error = None screenshot_error = None
if self.screenshot_copies > 0: if self.screenshot_copies > 0:
@@ -739,7 +786,6 @@ class RTKeyCamerasApi:
screenshot_error = str(e) screenshot_error = str(e)
_LOGGER.error(f"Ошибка скачивания скриншота для {intercom_id}: {e}") _LOGGER.error(f"Ошибка скачивания скриншота для {intercom_id}: {e}")
# 🆕 Скачиваем архив на диск (если включено)
archive_path = None archive_path = None
archive_error = None archive_error = None
if self.archive_copies > 0: if self.archive_copies > 0:
@@ -763,7 +809,7 @@ class RTKeyCamerasApi:
} }
_LOGGER.info( _LOGGER.info(
f"🔔 Новое событие для домофона {intercom_id}: " f"Новое событие для домофона {intercom_id}: "
f"{self.last_events[intercom_id]['description']} " f"{self.last_events[intercom_id]['description']} "
f"в {self.last_events[intercom_id]['local_time']}" f"в {self.last_events[intercom_id]['local_time']}"
) )
@@ -771,21 +817,26 @@ class RTKeyCamerasApi:
self._notify_event_listeners(intercom_id) self._notify_event_listeners(intercom_id)
async def _events_polling_loop(self): async def _events_polling_loop(self):
_LOGGER.info(f"🔄 Запущен цикл опроса событий (интервал {self.events_refresh_interval}с)") _LOGGER.info(f"Запущен цикл опроса событий (интервал {self.events_refresh_interval}с)")
while True: while True:
try: try:
for intercom_id in self.intercom_ids: tasks = [self.get_last_event(id) for id in self.intercom_ids]
last_event = await self.get_last_event(intercom_id) results = await asyncio.gather(*tasks, return_exceptions=True)
if not last_event:
for intercom_id, result in zip(self.intercom_ids, results):
if isinstance(result, Exception):
_LOGGER.error(f"Ошибка опроса событий для {intercom_id}: {result}")
continue
if not result:
continue continue
request_id = last_event.get("request_id") request_id = result.get("request_id")
last_request_id = self._last_request_ids.get(intercom_id) last_request_id = self._last_request_ids.get(intercom_id)
if request_id and request_id != last_request_id: if request_id and request_id != last_request_id:
self._last_request_ids[intercom_id] = request_id self._last_request_ids[intercom_id] = request_id
await self.update_last_event(intercom_id, last_event) await self.update_last_event(intercom_id, result)
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка в цикле опроса событий: {e}") _LOGGER.error(f"Ошибка в цикле опроса событий: {e}")
@@ -812,6 +863,7 @@ class RTKeyCamerasApi:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
self._events_polling_task = None self._events_polling_task = None
await self._close_session()
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -820,7 +872,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"cameras_api": cameras_api "cameras_api": cameras_api
} }
# 🆕 Инициализация (проверка FFmpeg)
await cameras_api.async_initialize() await cameras_api.async_initialize()
entry.add_update_listener(update_listener) entry.add_update_listener(update_listener)
+1
View File
@@ -19,6 +19,7 @@ from . import (
CONF_EVENT_SENSOR_ENABLED, CONF_EVENT_SENSOR_ENABLED,
CONF_ARCHIVE_PATH, CONF_ARCHIVE_PATH,
CONF_ARCHIVE_COPIES, CONF_ARCHIVE_COPIES,
CONF_ARCHIVE_DURATION,
CONF_SCREENSHOT_COPIES, CONF_SCREENSHOT_COPIES,
DATA_SCHEMA, DATA_SCHEMA,
OPTIONS_SCHEMA, OPTIONS_SCHEMA,
+3 -3
View File
@@ -100,9 +100,9 @@ class RTKeyEventSensor(SensorEntity):
"Тип события": self._event_data.get("event_type", ""), "Тип события": self._event_data.get("event_type", ""),
} }
archive_url = self._event_data.get("archive_url") archive_path = self._event_data.get("archive_path")
if archive_url: if archive_path:
attrs["Архив видео"] = archive_url attrs["Архив видео"] = archive_path
event = self._event_data.get("event", {}) event = self._event_data.get("event", {})
rfid = event.get("rfid") rfid = event.get("rfid")
@@ -18,6 +18,7 @@
"event_sensor_enabled": "Create event sensor entity", "event_sensor_enabled": "Create event sensor entity",
"archive_path": "Archive storage path", "archive_path": "Archive storage path",
"archive_copies": "Number of video copies (0 = disabled)", "archive_copies": "Number of video copies (0 = disabled)",
"archive_duration": "Archive video duration (sec)",
"screenshot_copies": "Number of screenshot copies (0 = disabled)" "screenshot_copies": "Number of screenshot copies (0 = disabled)"
}, },
"data_description": { "data_description": {
@@ -25,6 +26,7 @@
"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_path": "Relative to /media/ folder (e.g.: RTKey)",
"archive_copies": "Maximum number of saved videos per intercom", "archive_copies": "Maximum number of saved videos per intercom",
"archive_duration": "Duration of archived video fragment per event",
"screenshot_copies": "Maximum number of saved screenshots per intercom" "screenshot_copies": "Maximum number of saved screenshots per intercom"
} }
} }
@@ -49,6 +51,7 @@
"event_sensor_enabled": "Create event sensor entity", "event_sensor_enabled": "Create event sensor entity",
"archive_path": "Archive storage path", "archive_path": "Archive storage path",
"archive_copies": "Number of video copies (0 = disabled)", "archive_copies": "Number of video copies (0 = disabled)",
"archive_duration": "Archive video duration (sec)",
"screenshot_copies": "Number of screenshot copies (0 = disabled)" "screenshot_copies": "Number of screenshot copies (0 = disabled)"
}, },
"data_description": { "data_description": {
@@ -56,6 +59,7 @@
"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_path": "Relative to /media/ folder (e.g.: RTKey)",
"archive_copies": "Maximum number of saved videos per intercom", "archive_copies": "Maximum number of saved videos per intercom",
"archive_duration": "Duration of archived video fragment per event",
"screenshot_copies": "Maximum number of saved screenshots per intercom" "screenshot_copies": "Maximum number of saved screenshots per intercom"
} }
} }
@@ -18,6 +18,7 @@
"event_sensor_enabled": "Создавать сенсор последнего события", "event_sensor_enabled": "Создавать сенсор последнего события",
"archive_path": "Путь для сохранения архивов", "archive_path": "Путь для сохранения архивов",
"archive_copies": "Количество копий видео (0 = отключено)", "archive_copies": "Количество копий видео (0 = отключено)",
"archive_duration": "Длительность видео архива (сек)",
"screenshot_copies": "Количество копий скриншотов (0 = отключено)" "screenshot_copies": "Количество копий скриншотов (0 = отключено)"
}, },
"data_description": { "data_description": {
@@ -25,6 +26,7 @@
"camera_image_refresh_interval": "0 — отключить периодические скриншоты", "camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Относительно папки /media/ (например: RTKey)", "archive_path": "Относительно папки /media/ (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон", "archive_copies": "Максимальное количество сохранённых видео на домофон",
"archive_duration": "Длительность фрагмента видео архива на событие",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон" "screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
} }
} }
@@ -49,6 +51,7 @@
"event_sensor_enabled": "Создавать сенсор последнего события", "event_sensor_enabled": "Создавать сенсор последнего события",
"archive_path": "Путь для сохранения архивов", "archive_path": "Путь для сохранения архивов",
"archive_copies": "Количество копий видео (0 = отключено)", "archive_copies": "Количество копий видео (0 = отключено)",
"archive_duration": "Длительность видео архива (сек)",
"screenshot_copies": "Количество копий скриншотов (0 = отключено)" "screenshot_copies": "Количество копий скриншотов (0 = отключено)"
}, },
"data_description": { "data_description": {
@@ -56,6 +59,7 @@
"camera_image_refresh_interval": "0 — отключить периодические скриншоты", "camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Относительно папки /media/ (например: RTKey)", "archive_path": "Относительно папки /media/ (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон", "archive_copies": "Максимальное количество сохранённых видео на домофон",
"archive_duration": "Длительность фрагмента видео архива на событие",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон" "screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
} }
} }