2 Commits
Author SHA1 Message Date
sirmrak 97624d6750 Изменение максимального времени опроса событий до 3600 секунд 2026-06-28 16:19:54 +03:00
sirmrak a12554c010 Хранение скриншотов и архивов на диск вместо in-memory
— Замена event_screenshot_enabled/event_archive_enabled на screenshot_copies/archive_copies (0 = отключено)
— Новый параметр archive_path для пути сохранения (относительно /media/)
— Скачивание скриншотов и видео-архивов событий на локальный диск с ограничением копий
— Проверка FFmpeg при инициализации, поддержка file:// для stream_source
— Обновлены переводы (en/ru) и config_flow
2026-06-28 16:09:47 +03:00
6 changed files with 336 additions and 141 deletions
+232 -78
View File
@@ -3,11 +3,10 @@ import functools
import logging import logging
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import requests import requests
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
@@ -15,7 +14,6 @@ from homeassistant.helpers import selector
DOMAIN = "rtkey" DOMAIN = "rtkey"
PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR] PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# === Константы настроек === # === Константы настроек ===
@@ -30,12 +28,14 @@ CONF_EVENTS_ENABLED = "events_enabled"
CONF_EVENTS_REFRESH_INTERVAL = "events_refresh_interval" CONF_EVENTS_REFRESH_INTERVAL = "events_refresh_interval"
CONF_EVENT_TIME_LAG = "event_time_lag" CONF_EVENT_TIME_LAG = "event_time_lag"
CONF_PRE_EVENT_SECONDS = "pre_event_seconds" CONF_PRE_EVENT_SECONDS = "pre_event_seconds"
CONF_EVENT_SCREENSHOT_ENABLED = "event_screenshot_enabled"
CONF_EVENT_SCREENSHOT_QUALITY = "event_screenshot_quality" 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_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 TOKEN_REFRESH_BUFFER = 300
DATA_SCHEMA = { DATA_SCHEMA = {
@@ -46,7 +46,6 @@ DATA_SCHEMA = {
OPTIONS_SCHEMA = { OPTIONS_SCHEMA = {
vol.Optional(CONF_NAME): str, vol.Optional(CONF_NAME): str,
vol.Optional(CONF_TOKEN): str, vol.Optional(CONF_TOKEN): str,
vol.Optional(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, default=60): selector.NumberSelector( vol.Optional(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, default=60): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=0, max=600, step=30, min=0, max=600, step=30,
@@ -54,7 +53,6 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек", unit_of_measurement="сек",
) )
), ),
vol.Optional(CONF_SCREENSHOT_QUALITY, default="medium"): selector.SelectSelector( vol.Optional(CONF_SCREENSHOT_QUALITY, default="medium"): selector.SelectSelector(
selector.SelectSelectorConfig( selector.SelectSelectorConfig(
options=[ options=[
@@ -66,7 +64,6 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN, mode=selector.SelectSelectorMode.DROPDOWN,
) )
), ),
vol.Optional(CONF_LOG_LEVEL, default="INFO"): selector.SelectSelector( vol.Optional(CONF_LOG_LEVEL, default="INFO"): selector.SelectSelector(
selector.SelectSelectorConfig( selector.SelectSelectorConfig(
options=[ options=[
@@ -78,17 +75,14 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN, mode=selector.SelectSelectorMode.DROPDOWN,
) )
), ),
vol.Optional(CONF_EVENTS_ENABLED, default=True): bool, vol.Optional(CONF_EVENTS_ENABLED, default=True): bool,
vol.Optional(CONF_EVENTS_REFRESH_INTERVAL, default=15): selector.NumberSelector( vol.Optional(CONF_EVENTS_REFRESH_INTERVAL, default=15): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=5, max=60, step=5, min=5, max=3600, step=5,
mode=selector.NumberSelectorMode.SLIDER, mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="сек", unit_of_measurement="сек",
) )
), ),
vol.Optional(CONF_EVENT_TIME_LAG, default=25): selector.NumberSelector( vol.Optional(CONF_EVENT_TIME_LAG, default=25): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=0, max=60, step=5, min=0, max=60, step=5,
@@ -96,7 +90,6 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек", unit_of_measurement="сек",
) )
), ),
vol.Optional(CONF_PRE_EVENT_SECONDS, default=10): selector.NumberSelector( vol.Optional(CONF_PRE_EVENT_SECONDS, default=10): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=0, max=30, step=5, min=0, max=30, step=5,
@@ -104,9 +97,6 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек", unit_of_measurement="сек",
) )
), ),
vol.Optional(CONF_EVENT_SCREENSHOT_ENABLED, default=True): bool,
vol.Optional(CONF_EVENT_SCREENSHOT_QUALITY, default="large"): selector.SelectSelector( vol.Optional(CONF_EVENT_SCREENSHOT_QUALITY, default="large"): selector.SelectSelector(
selector.SelectSelectorConfig( selector.SelectSelectorConfig(
options=[ options=[
@@ -118,17 +108,20 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN, mode=selector.SelectSelectorMode.DROPDOWN,
) )
), ),
# 🆕 Настройки локального хранения
vol.Optional(CONF_EVENT_ARCHIVE_ENABLED, default=True): bool, vol.Optional(CONF_ARCHIVE_PATH, default="media/RTKey"): str,
vol.Optional(CONF_ARCHIVE_COPIES, default=5): selector.NumberSelector(
vol.Optional(CONF_ARCHIVE_DURATION, default=30): selector.NumberSelector(
selector.NumberSelectorConfig( selector.NumberSelectorConfig(
min=10, max=120, step=10, min=0, max=10, step=1,
mode=selector.NumberSelectorMode.SLIDER, mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="сек", )
),
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, 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.events_refresh_interval = entry.options.get(CONF_EVENTS_REFRESH_INTERVAL, 15)
self.event_time_lag = entry.options.get(CONF_EVENT_TIME_LAG, 25) 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.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_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.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") 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 = None
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
# Информация о камерах # Информация о камерах
self.camera_tokens = {} self.camera_tokens = {}
self._cameras_cache = None self._cameras_cache = None
self._cameras_cache_ts = 0 self._cameras_cache_ts = 0
# Кэш картинок # Кэш картинок
self._image_cache = {} self._image_cache = {}
self._image_cache_lock = asyncio.Lock() self._image_cache_lock = asyncio.Lock()
# ✅ НОВОЕ: Отслеживание недоступных камер (404) # Отслеживание недоступных камер (404)
self._unavailable_cameras: set[str] = set() self._unavailable_cameras: set[str] = set()
# ✅ НОВОЕ: Флаг протухших токенов (403) # Флаг протухших токенов (403)
self._tokens_invalid = False self._tokens_invalid = False
self._tokens_refresh_lock = asyncio.Lock() self._tokens_refresh_lock = asyncio.Lock()
@@ -185,21 +180,171 @@ class RTKeyCamerasApi:
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
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: 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.""" """Получаем название камеры по её ID."""
token_info = self.camera_tokens.get(camera_id) token_info = self.camera_tokens.get(camera_id)
if token_info: if token_info:
return token_info.get("title", "Неизвестная камера") return token_info.get("title", "Неизвестная камера")
return "Неизвестная камера" 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):
if callback not in self._event_listeners: if callback not in self._event_listeners:
self._event_listeners.append(callback) self._event_listeners.append(callback)
@@ -214,12 +359,12 @@ class RTKeyCamerasApi:
callback(intercom_id) callback(intercom_id)
except Exception as e: except Exception as e:
_LOGGER.error(f"Ошибка в event listener: {e}") _LOGGER.error(f"Ошибка в event listener: {e}")
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( r = await self.hass.async_add_executor_job(
@@ -233,10 +378,10 @@ class RTKeyCamerasApi:
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
devices = data.get("data", []) devices = data.get("data", [])
if devices: if devices:
self.user_token = devices[0].get("userToken") self.user_token = devices[0].get("userToken")
for device in devices: for device in devices:
uid = device.get("uid") uid = device.get("uid")
if uid: if uid:
@@ -255,7 +400,7 @@ class RTKeyCamerasApi:
"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)} устройств")
@@ -265,17 +410,17 @@ class RTKeyCamerasApi:
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.""" """Получаем информацию о домофонах из отдельного API."""
try: try:
@@ -322,10 +467,10 @@ class RTKeyCamerasApi:
f"Построены маппинги: {len(self.intercom_camera_map)} камер домофонов, " f"Построены маппинги: {len(self.intercom_camera_map)} камер домофонов, "
f"{len(self.rfid_names)} ключей" f"{len(self.rfid_names)} ключей"
) )
async def get_cameras_info(self): async def get_cameras_info(self):
await self._fetch_cameras() await self._fetch_cameras()
items = [] items = []
for uid, info in self.camera_tokens.items(): for uid, info in self.camera_tokens.items():
items.append({ items.append({
@@ -338,18 +483,16 @@ class RTKeyCamerasApi:
"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: if not self.intercom_ids:
return await self._fetch_intercoms() 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.""" """Получаем скриншот камеры с обработкой ошибок 403 и 404."""
async with self._image_cache_lock: async with self._image_cache_lock:
@@ -357,19 +500,18 @@ class RTKeyCamerasApi:
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) token_info = self.camera_tokens.get(camera_id)
screenshot_token = token_info.get("screenshot_token") if token_info else None 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 user_token = (token_info.get("user_token") or self.user_token) if token_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 = 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 += f"?token={screenshot_token}&_={int(time.time() * 1000)}"
_LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id}): {url}") _LOGGER.debug(f"Запрос скриншота для {camera_name} ({camera_id}): {url}")
r = await self.hass.async_add_executor_job( r = await self.hass.async_add_executor_job(
functools.partial( functools.partial(
@@ -385,8 +527,7 @@ class RTKeyCamerasApi:
timeout=15, timeout=15,
) )
) )
# ✅ Обработка 403 — токен протух
if r.status_code == 403 and not retry: if r.status_code == 403 and not retry:
_LOGGER.warning( _LOGGER.warning(
f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..." f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..."
@@ -400,8 +541,7 @@ class RTKeyCamerasApi:
f"❌ Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией" f"❌ Повторный 403 для {camera_name} ({camera_id}) — проблема с авторизацией"
) )
return None return None
# ✅ Обработка 404 — камера недоступна
if r.status_code == 404: if r.status_code == 404:
if camera_id not in self._unavailable_cameras: if camera_id not in self._unavailable_cameras:
_LOGGER.warning( _LOGGER.warning(
@@ -409,13 +549,12 @@ class RTKeyCamerasApi:
) )
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_code == 200 and len(r.content) > 2000:
image_data = r.content image_data = r.content
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}) снова доступна")
@@ -429,8 +568,7 @@ class RTKeyCamerasApi:
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:
"""Получаем скриншот на момент события (с учётом лага).""" """Получаем скриншот на момент события (с учётом лага)."""
token_info = self.camera_tokens.get(camera_id) token_info = self.camera_tokens.get(camera_id)
@@ -481,17 +619,17 @@ class RTKeyCamerasApi:
if streamer_token: if streamer_token:
return f"{base}?{params}&token={streamer_token}" return f"{base}?{params}&token={streamer_token}"
return f"{base}?{params}" return f"{base}?{params}"
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) token_info = self.camera_tokens.get(camera_id)
streamer_token = token_info.get("streamer_token") if token_info else None 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" 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" params = "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}" return f"{base}?{params}&token={streamer_token}"
return f"{base}?{params}" return f"{base}?{params}"
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( await self.hass.async_add_executor_job(
@@ -591,13 +729,25 @@ class RTKeyCamerasApi:
if not event_timestamp: if not event_timestamp:
return return
screenshot_data = None # 🆕 Скачиваем скриншот на диск (если включено)
if self.event_screenshot_enabled: screenshot_path = None
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp) 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_path = None
archive_url = self.get_event_archive_url(camera_id, event_timestamp) 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] = { self.last_events[intercom_id] = {
"event": event, "event": event,
@@ -606,8 +756,10 @@ class RTKeyCamerasApi:
"local_time": self._format_local_time(event.get("raised_at", "")), "local_time": self._format_local_time(event.get("raised_at", "")),
"description": self._get_event_description(event), "description": self._get_event_description(event),
"event_type": event.get("event_type", ""), "event_type": event.get("event_type", ""),
"screenshot_data": screenshot_data, "screenshot_path": screenshot_path,
"archive_url": archive_url, "archive_path": archive_path,
"screenshot_error": screenshot_error,
"archive_error": archive_error,
} }
_LOGGER.info( _LOGGER.info(
@@ -667,7 +819,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"cameras_api": cameras_api "cameras_api": cameras_api
} }
# 🆕 Инициализация (проверка FFmpeg)
await cameras_api.async_initialize()
entry.add_update_listener(update_listener) entry.add_update_listener(update_listener)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 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: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
cameras_api = hass.data[DOMAIN][entry.entry_id]["cameras_api"] cameras_api = hass.data[DOMAIN][entry.entry_id]["cameras_api"]
await cameras_api.stop_events_polling() await cameras_api.stop_events_polling()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+33 -19
View File
@@ -1,13 +1,12 @@
import datetime import datetime
from pathlib import Path
from typing import Any from typing import Any
from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from . import _LOGGER, DOMAIN, RTKeyCamerasApi, TOKEN_REFRESH_BUFFER from . import _LOGGER, DOMAIN, RTKeyCamerasApi, TOKEN_REFRESH_BUFFER
@@ -18,21 +17,21 @@ async def async_setup_entry(
) -> None: ) -> None:
cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"] cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"]
cameras_info = await cameras_api.get_cameras_info() cameras_info = await cameras_api.get_cameras_info()
entities = [ entities = [
RTKeyCamera(hass, config_entry, cameras_api, camera_info) RTKeyCamera(hass, config_entry, cameras_api, camera_info)
for camera_info in cameras_info["data"]["items"] for camera_info in cameras_info["data"]["items"]
] ]
# Добавляем архивные камеры событий для домофонов # 🆕 Добавляем архивные камеры только если archive_copies > 0
if cameras_api.event_archive_enabled: if cameras_api.archive_copies > 0:
intercoms_info = await cameras_api.get_intercoms_info() intercoms_info = await cameras_api.get_intercoms_info()
for intercom_info in intercoms_info.get("data", {}).get("devices", []): for intercom_info in intercoms_info.get("data", {}).get("devices", []):
entities.append( entities.append(
RTKeyEventCamera(hass, config_entry, cameras_api, intercom_info) RTKeyEventCamera(hass, config_entry, cameras_api, intercom_info)
) )
else: else:
_LOGGER.info("Архивные видео событий отключены") _LOGGER.info("Архивные видео событий отключены (archive_copies=0)")
async_add_entities(entities) async_add_entities(entities)
@@ -86,7 +85,6 @@ class RTKeyCamera(Camera):
async def _stream_refresh(self, now: datetime.datetime) -> None: async def _stream_refresh(self, now: datetime.datetime) -> None:
try: try:
# Если токены протухли — обновляем их перед получением URL
if self.cameras_api._tokens_invalid: if self.cameras_api._tokens_invalid:
_LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}") _LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}")
await self.cameras_api.refresh_tokens() await self.cameras_api.refresh_tokens()
@@ -108,12 +106,10 @@ class RTKeyCamera(Camera):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Камера доступна, если она не в списке недоступных."""
return self.cameras_api.is_camera_available(self.camera_id) return self.cameras_api.is_camera_available(self.camera_id)
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
"""Атрибуты с динамической проверкой доступности камеры."""
if self.cameras_api.is_camera_available(self.camera_id): if self.cameras_api.is_camera_available(self.camera_id):
status = self._device_status_title status = self._device_status_title
else: else:
@@ -145,7 +141,7 @@ class RTKeyCamera(Camera):
class RTKeyEventCamera(Camera): class RTKeyEventCamera(Camera):
"""Камера для просмотра архивного видео на момент последнего события.""" """Камера для просмотра архивного видео на момент последнего события (локальный файл)."""
_attr_should_poll = False _attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -176,7 +172,8 @@ class RTKeyEventCamera(Camera):
self._event_description = None self._event_description = None
self._event_time = 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: async def async_added_to_hass(self) -> None:
await super().async_added_to_hass() await super().async_added_to_hass()
@@ -199,20 +196,35 @@ class RTKeyEventCamera(Camera):
if not event_info: if not event_info:
return return
archive_url = event_info.get("archive_url") archive_path = event_info.get("archive_path")
if archive_url: archive_error = event_info.get("archive_error")
self._archive_url = archive_url
if archive_path:
self._archive_path = archive_path
self._archive_error = None
self._event_description = event_info.get("description") self._event_description = event_info.get("description")
self._event_time = event_info.get("local_time") self._event_time = event_info.get("local_time")
if self.stream: if self.stream:
_LOGGER.debug(f"Обновляем URL архива для {self.device_name}") _LOGGER.debug(f"Обновляем путь архива для {self.device_name}")
self.stream.update_source(archive_url) # Для локального файла stream не обновляем через update_source
# stream_source() будет вызван при запросе
self.async_write_ha_state() 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: 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 @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
@@ -221,8 +233,10 @@ class RTKeyEventCamera(Camera):
attrs["Событие"] = self._event_description attrs["Событие"] = self._event_description
if self._event_time: if self._event_time:
attrs["Время"] = self._event_time attrs["Время"] = self._event_time
if self._archive_url: if self._archive_path:
attrs["URL архива"] = self._archive_url attrs["Путь к архиву"] = self._archive_path
if self._archive_error:
attrs["Ошибка скачивания"] = self._archive_error
return attrs return attrs
@property @property
+4 -6
View File
@@ -5,7 +5,6 @@ from homeassistant.config_entries import (
OptionsFlowWithConfigEntry, OptionsFlowWithConfigEntry,
) )
from homeassistant.core import callback from homeassistant.core import callback
from . import ( from . import (
CONF_NAME, CONF_NAME,
CONF_TOKEN, CONF_TOKEN,
@@ -16,11 +15,11 @@ from . import (
CONF_EVENTS_REFRESH_INTERVAL, CONF_EVENTS_REFRESH_INTERVAL,
CONF_EVENT_TIME_LAG, CONF_EVENT_TIME_LAG,
CONF_PRE_EVENT_SECONDS, CONF_PRE_EVENT_SECONDS,
CONF_EVENT_SCREENSHOT_ENABLED,
CONF_EVENT_SCREENSHOT_QUALITY, CONF_EVENT_SCREENSHOT_QUALITY,
CONF_EVENT_ARCHIVE_ENABLED,
CONF_ARCHIVE_DURATION,
CONF_EVENT_SENSOR_ENABLED, CONF_EVENT_SENSOR_ENABLED,
CONF_ARCHIVE_PATH,
CONF_ARCHIVE_COPIES,
CONF_SCREENSHOT_COPIES,
DATA_SCHEMA, DATA_SCHEMA,
OPTIONS_SCHEMA, OPTIONS_SCHEMA,
DOMAIN, DOMAIN,
@@ -64,11 +63,10 @@ class RTKeyConfigFlow(ConfigFlow, domain=DOMAIN):
options=user_input, options=user_input,
) )
# ✅ Добавляем add_suggested_values_to_schema для корректной работы переводов
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=self.add_suggested_values_to_schema( data_schema=self.add_suggested_values_to_schema(
vol.Schema(DATA_SCHEMA).extend(OPTIONS_SCHEMA), vol.Schema(DATA_SCHEMA).extend(OPTIONS_SCHEMA),
{}, # Пустые значения по умолчанию для первой настройки {},
), ),
) )
+35 -18
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
from typing import Any from typing import Any
from homeassistant.components.image import ImageEntity from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback 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.event import async_track_time_interval
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from . import CONF_CAMERA_IMAGE_REFRESH_INTERVAL, DOMAIN, RTKeyCamerasApi, _LOGGER from . import CONF_CAMERA_IMAGE_REFRESH_INTERVAL, DOMAIN, RTKeyCamerasApi, _LOGGER
@@ -19,8 +18,7 @@ async def async_setup_entry(
) -> None: ) -> None:
cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"] cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"]
cameras_info = await cameras_api.get_cameras_info() cameras_info = await cameras_api.get_cameras_info()
# Проверяем, включены ли обычные скриншоты
interval = config_entry.options.get(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, 60) interval = config_entry.options.get(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, 60)
entities = [] entities = []
@@ -34,15 +32,15 @@ async def async_setup_entry(
else: else:
_LOGGER.info("Обычные скриншоты отключены (interval=0)") _LOGGER.info("Обычные скриншоты отключены (interval=0)")
# Скриншоты событий # 🆕 Скриншоты событий только если screenshot_copies > 0
if cameras_api.event_screenshot_enabled: if cameras_api.screenshot_copies > 0:
intercoms_info = await cameras_api.get_intercoms_info() intercoms_info = await cameras_api.get_intercoms_info()
for intercom_info in intercoms_info.get("data", {}).get("devices", []): for intercom_info in intercoms_info.get("data", {}).get("devices", []):
entities.append( entities.append(
RTKeyEventImage(hass, config_entry, cameras_api, intercom_info) RTKeyEventImage(hass, config_entry, cameras_api, intercom_info)
) )
else: else:
_LOGGER.info("Скриншоты событий отключены") _LOGGER.info("Скриншоты событий отключены (screenshot_copies=0)")
async_add_entities(entities) async_add_entities(entities)
@@ -97,8 +95,6 @@ class RTKeyCameraImageEntity(ImageEntity):
await super().async_will_remove_from_hass() await super().async_will_remove_from_hass()
async def _async_update_image(self, now: datetime | None = None) -> None: async def _async_update_image(self, now: datetime | None = None) -> None:
"""Продолжаем опрашивать API даже если камера недоступна,
чтобы узнать, когда она снова появится."""
try: try:
image = await self.cameras_api.get_camera_image(self.camera_id) image = await self.cameras_api.get_camera_image(self.camera_id)
if image: if image:
@@ -114,12 +110,10 @@ class RTKeyCameraImageEntity(ImageEntity):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Сущность доступна, если камера не в списке недоступных."""
return self.cameras_api.is_camera_available(self.camera_id) return self.cameras_api.is_camera_available(self.camera_id)
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
"""Атрибуты с динамической проверкой доступности камеры."""
if self.cameras_api.is_camera_available(self.camera_id): if self.cameras_api.is_camera_available(self.camera_id):
status = self._device_status_title status = self._device_status_title
else: else:
@@ -147,7 +141,7 @@ class RTKeyCameraImageEntity(ImageEntity):
class RTKeyEventImage(ImageEntity): class RTKeyEventImage(ImageEntity):
"""Скриншот на момент последнего события домофона.""" """Скриншот на момент последнего события домофона (локальный файл)."""
_attr_should_poll = False _attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -176,7 +170,8 @@ class RTKeyEventImage(ImageEntity):
self._attr_name = "Скриншот события" self._attr_name = "Скриншот события"
self._attr_image_last_updated = None 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_description = None
self._event_time = None self._event_time = None
@@ -201,13 +196,15 @@ class RTKeyEventImage(ImageEntity):
if not event_info: if not event_info:
return return
screenshot_data = event_info.get("screenshot_data") screenshot_path = event_info.get("screenshot_path")
if screenshot_data: screenshot_error = event_info.get("screenshot_error")
self._cached_image = screenshot_data
if screenshot_path:
self._screenshot_path = screenshot_path
self._screenshot_error = None
self._event_description = event_info.get("description") self._event_description = event_info.get("description")
self._event_time = event_info.get("local_time") self._event_time = event_info.get("local_time")
# ✅ Используем время события, а не текущее время
event_timestamp = event_info.get("timestamp") event_timestamp = event_info.get("timestamp")
if event_timestamp: if event_timestamp:
self._attr_image_last_updated = datetime.fromtimestamp( self._attr_image_last_updated = datetime.fromtimestamp(
@@ -219,9 +216,25 @@ class RTKeyEventImage(ImageEntity):
self.async_write_ha_state() self.async_write_ha_state()
_LOGGER.debug(f"Обновлён скриншот события для {self.device_name}") _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: 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 @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
@@ -230,6 +243,10 @@ class RTKeyEventImage(ImageEntity):
attrs["Событие"] = self._event_description attrs["Событие"] = self._event_description
if self._event_time: if self._event_time:
attrs["Время"] = 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 return attrs
@property @property
+16 -10
View File
@@ -14,15 +14,18 @@
"events_refresh_interval": "Event polling interval (sec)", "events_refresh_interval": "Event polling interval (sec)",
"event_time_lag": "Camera time lag (sec)", "event_time_lag": "Camera time lag (sec)",
"pre_event_seconds": "Seconds before event to show", "pre_event_seconds": "Seconds before event to show",
"event_screenshot_enabled": "Create event screenshot entity",
"event_screenshot_quality": "Event screenshot quality", "event_screenshot_quality": "Event screenshot quality",
"event_archive_enabled": "Create event archive camera", "event_sensor_enabled": "Create event sensor entity",
"archive_duration": "Archive duration (sec)", "archive_path": "Archive storage path",
"event_sensor_enabled": "Create event sensor entity" "archive_copies": "Number of video copies (0 = disabled)",
"screenshot_copies": "Number of screenshot copies (0 = disabled)"
}, },
"data_description": { "data_description": {
"token": "Get token via browser developer tools (F12) on key.rt.ru", "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)", "events_refresh_interval": "Event polling interval (sec)",
"event_time_lag": "Camera time lag (sec)", "event_time_lag": "Camera time lag (sec)",
"pre_event_seconds": "Seconds before event to show", "pre_event_seconds": "Seconds before event to show",
"event_screenshot_enabled": "Create event screenshot entity",
"event_screenshot_quality": "Event screenshot quality", "event_screenshot_quality": "Event screenshot quality",
"event_archive_enabled": "Create event archive camera", "event_sensor_enabled": "Create event sensor entity",
"archive_duration": "Archive duration (sec)", "archive_path": "Archive storage path",
"event_sensor_enabled": "Create event sensor entity" "archive_copies": "Number of video copies (0 = disabled)",
"screenshot_copies": "Number of screenshot copies (0 = disabled)"
}, },
"data_description": { "data_description": {
"token": "Get token via browser developer tools (F12) on key.rt.ru", "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"
} }
} }
} }
+16 -10
View File
@@ -14,15 +14,18 @@
"events_refresh_interval": "Интервал опроса событий (сек)", "events_refresh_interval": "Интервал опроса событий (сек)",
"event_time_lag": "Задержка времени камеры (сек)", "event_time_lag": "Задержка времени камеры (сек)",
"pre_event_seconds": "Секунд до события для показа", "pre_event_seconds": "Секунд до события для показа",
"event_screenshot_enabled": "Создавать сущность скриншота события",
"event_screenshot_quality": "Качество скриншота события", "event_screenshot_quality": "Качество скриншота события",
"event_archive_enabled": "Создавать камеру архива событий", "event_sensor_enabled": "Создавать сенсор последнего события",
"archive_duration": "Длительность архива (сек)", "archive_path": "Путь для сохранения архивов",
"event_sensor_enabled": "Создавать сенсор последнего события" "archive_copies": "Количество копий видео (0 = отключено)",
"screenshot_copies": "Количество копий скриншотов (0 = отключено)"
}, },
"data_description": { "data_description": {
"token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru", "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": "Интервал опроса событий (сек)", "events_refresh_interval": "Интервал опроса событий (сек)",
"event_time_lag": "Задержка времени камеры (сек)", "event_time_lag": "Задержка времени камеры (сек)",
"pre_event_seconds": "Секунд до события для показа", "pre_event_seconds": "Секунд до события для показа",
"event_screenshot_enabled": "Создавать сущность скриншота события",
"event_screenshot_quality": "Качество скриншота события", "event_screenshot_quality": "Качество скриншота события",
"event_archive_enabled": "Создавать камеру архива событий", "event_sensor_enabled": "Создавать сенсор последнего события",
"archive_duration": "Длительность архива (сек)", "archive_path": "Путь для сохранения архивов",
"event_sensor_enabled": "Создавать сенсор последнего события" "archive_copies": "Количество копий видео (0 = отключено)",
"screenshot_copies": "Количество копий скриншотов (0 = отключено)"
}, },
"data_description": { "data_description": {
"token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru", "token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru",
"camera_image_refresh_interval": "0 — отключить периодические скриншоты" "camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Относительно папки /media/ (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
} }
} }
} }