Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1f1ff89d8 | ||
|
|
75d985f64a | ||
|
|
8b65cb43eb | ||
|
|
02a3d821e3 | ||
|
|
0e07bc8c9d | ||
|
|
8472b91cd1 | ||
|
|
ff5406828c | ||
|
|
c121261f3d | ||
|
|
15a97be8e2 |
@@ -1,3 +1,4 @@
|
|||||||
|
<img src="custom_components/rtkey/icon.png" width="128"> </img>
|
||||||
# RT Key Integration for Home Assistant
|
# RT Key Integration for Home Assistant
|
||||||
|
|
||||||
[](https://www.home-assistant.io/)
|
[](https://www.home-assistant.io/)
|
||||||
|
|||||||
+345
-240
@@ -1,16 +1,20 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
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 homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
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 +35,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,14 +113,21 @@ 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="RTKey"): str,
|
||||||
vol.Optional(CONF_ARCHIVE_COPIES, default=5): selector.NumberSelector(
|
vol.Optional(CONF_ARCHIVE_COPIES, default=5): selector.NumberSelector(
|
||||||
selector.NumberSelectorConfig(
|
selector.NumberSelectorConfig(
|
||||||
min=0, max=10, step=1,
|
min=0, max=10, step=1,
|
||||||
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 +138,42 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ApiResponse:
|
||||||
|
status: int
|
||||||
|
body: bytes
|
||||||
|
|
||||||
|
|
||||||
|
_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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mkdir_parents(p: Path):
|
||||||
|
"""Path.mkdir с keyword args для executor."""
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
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 +189,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, "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()
|
|
||||||
|
# HA managed aiohttp session
|
||||||
|
self._session: aiohttp.ClientSession = async_get_clientsession(hass)
|
||||||
|
|
||||||
# Информация о камерах
|
# Информация о камерах
|
||||||
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 +221,73 @@ 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
|
||||||
|
|
||||||
async def async_initialize(self):
|
async def async_initialize(self):
|
||||||
"""Инициализация после создания (для async операций)."""
|
|
||||||
await self._check_ffmpeg()
|
await self._check_ffmpeg()
|
||||||
|
await self._ensure_media_dirs()
|
||||||
|
|
||||||
|
async def _ensure_media_dirs(self):
|
||||||
|
base = self._get_media_base_path() / self.archive_path
|
||||||
|
dirs_to_create = [base / "archives", base / "screenshots"]
|
||||||
|
for dir_path in dirs_to_create:
|
||||||
|
await self.hass.async_add_executor_job(_mkdir_parents, dir_path)
|
||||||
|
_LOGGER.info(f"Медиа-папка: {dir_path}")
|
||||||
|
_LOGGER.info(f"Базовый путь для медиа: {base}")
|
||||||
|
|
||||||
|
async def _ensure_intercom_dirs(self, intercom_ids: list[str]):
|
||||||
|
for intercom_id in intercom_ids:
|
||||||
|
for dir_path in (self._get_archive_dir(intercom_id), self._get_screenshot_dir(intercom_id)):
|
||||||
|
await self.hass.async_add_executor_job(_mkdir_parents, dir_path)
|
||||||
|
|
||||||
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}")
|
||||||
|
|
||||||
|
# === Медиа-пути ===
|
||||||
|
|
||||||
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:
|
||||||
|
if "media" in media_dirs:
|
||||||
return Path(media_dirs["media"])
|
return Path(media_dirs["media"])
|
||||||
# Fallback
|
if "local" in media_dirs:
|
||||||
return Path("/media")
|
return Path(media_dirs["local"])
|
||||||
|
return Path(self.hass.config.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 +297,18 @@ 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(_mkdir_parents, archive_dir)
|
||||||
|
|
||||||
# Имя файла
|
|
||||||
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)
|
if await self.hass.async_add_executor_job(lambda p: p.exists() and p.stat().st_size > 0, filepath):
|
||||||
# archive_duration по умолчанию 30 сек
|
_LOGGER.info(f"Архив уже существует: {filepath}")
|
||||||
archive_duration = 30 # Можно вынести в настройки позже
|
return str(filepath)
|
||||||
duration = self.pre_event_seconds + self.event_time_lag + archive_duration
|
|
||||||
|
duration = self.archive_duration
|
||||||
|
|
||||||
_LOGGER.info(f"Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}")
|
_LOGGER.info(f"Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}")
|
||||||
|
|
||||||
@@ -258,6 +320,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 +329,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 +357,51 @@ 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(_mkdir_parents, screenshot_dir)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
# Получаем скриншот через существующий метод
|
if await self.hass.async_add_executor_job(lambda p: p.exists() and p.stat().st_size > 0, filepath):
|
||||||
|
_LOGGER.info(f"Скриншот уже существует: {filepath}")
|
||||||
|
return str(filepath)
|
||||||
|
|
||||||
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):
|
||||||
"""Удаляем старые архивные видео, если превышено количество копий."""
|
def _cleanup():
|
||||||
archive_dir = self._get_archive_dir(intercom_id)
|
if not directory.exists():
|
||||||
if not archive_dir.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 +419,55 @@ 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, HA managed session) ===
|
||||||
|
|
||||||
|
async def _api_get(self, url: str | URL, headers: dict, timeout: int = 20) -> _ApiResponse | None:
|
||||||
|
"""GET-запрос. Читает body внутри context manager, возвращает status + bytes."""
|
||||||
|
try:
|
||||||
|
async with self._session.get(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||||
|
) as r:
|
||||||
|
body = await r.read()
|
||||||
|
return _ApiResponse(status=r.status, body=body)
|
||||||
|
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_data: dict | None = None, timeout: int = 10) -> _ApiResponse | None:
|
||||||
|
"""POST-запрос. Читает body внутри context manager."""
|
||||||
|
try:
|
||||||
|
async with self._session.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
json=json_data or {},
|
||||||
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||||
|
) as r:
|
||||||
|
body = await r.read()
|
||||||
|
return _ApiResponse(status=r.status, body=body)
|
||||||
|
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 = json.loads(r.body)
|
||||||
devices = data.get("data", [])
|
devices = data.get("data", [])
|
||||||
|
|
||||||
if devices:
|
if devices:
|
||||||
@@ -385,66 +476,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 = json.loads(r.body)
|
||||||
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 +565,193 @@ 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 = r.body
|
||||||
|
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 = r.body
|
||||||
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, retry: bool = False) -> str | None:
|
||||||
token_info = self.camera_tokens.get(camera_id)
|
if self._tokens_invalid and not retry:
|
||||||
streamer_token = token_info.get("streamer_token") if token_info else None
|
_LOGGER.debug(f"Токены протухшие, обновляем перед запросом стрима для {camera_id}")
|
||||||
|
await self.refresh_tokens()
|
||||||
|
|
||||||
base = f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/live.mp4"
|
info = self.camera_tokens.get(camera_id)
|
||||||
params = "mp4-fragment-length=0.5&mp4-use-speed=0&mp4-afiller=1"
|
streamer_token = info.streamer_token if info else None
|
||||||
|
|
||||||
|
url = URL(f"https://live-vdk4.camera.rt.ru/stream/{camera_id}/live.mp4").with_query({
|
||||||
|
"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}"
|
|
||||||
|
r = await self._api_get(url, _DEFAULT_HEADERS, timeout=10)
|
||||||
|
if r is None:
|
||||||
|
return str(url)
|
||||||
|
|
||||||
|
if r.status == 403 and not retry:
|
||||||
|
camera_name = self.get_camera_name(camera_id)
|
||||||
|
_LOGGER.warning(f"Токен стрима для {camera_name} ({camera_id}) протух (403), запускаем обновление...")
|
||||||
|
self._tokens_invalid = True
|
||||||
|
await self.refresh_tokens()
|
||||||
|
return await self.get_camera_stream_url(camera_id, retry=True)
|
||||||
|
|
||||||
|
if r.status == 403 and retry:
|
||||||
|
camera_name = self.get_camera_name(camera_id)
|
||||||
|
_LOGGER.error(f"Повторный 403 для стрима {camera_name} ({camera_id}) — проблема с авторизацией")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if r.status == 200:
|
||||||
|
return str(url)
|
||||||
|
|
||||||
|
_LOGGER.warning(f"Стрим для {camera_id}: статус {r.status}")
|
||||||
|
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 +793,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 = json.loads(r.body)
|
||||||
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 +831,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 +840,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 +863,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 +871,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}")
|
||||||
@@ -801,6 +906,7 @@ class RTKeyCamerasApi:
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self._fetch_intercoms()
|
await self._fetch_intercoms()
|
||||||
|
await self._ensure_intercom_dirs(self.intercom_ids)
|
||||||
|
|
||||||
self._events_polling_task = asyncio.create_task(self._events_polling_loop())
|
self._events_polling_task = asyncio.create_task(self._events_polling_loop())
|
||||||
|
|
||||||
@@ -820,7 +926,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,13 +1,13 @@
|
|||||||
import datetime
|
|
||||||
from pathlib import Path
|
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
|
||||||
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.entity import EntityCategory
|
from homeassistant.helpers.entity import EntityCategory
|
||||||
from . import _LOGGER, DOMAIN, RTKeyCamerasApi, TOKEN_REFRESH_BUFFER
|
|
||||||
|
from . import _LOGGER, DOMAIN, RTKeyCamerasApi
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -23,7 +23,6 @@ async def async_setup_entry(
|
|||||||
for camera_info in cameras_info["data"]["items"]
|
for camera_info in cameras_info["data"]["items"]
|
||||||
]
|
]
|
||||||
|
|
||||||
# 🆕 Добавляем архивные камеры только если archive_copies > 0
|
|
||||||
if cameras_api.archive_copies > 0:
|
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", []):
|
||||||
@@ -63,38 +62,6 @@ class RTKeyCamera(Camera):
|
|||||||
|
|
||||||
self._attr_unique_id = f"camera_{self.camera_id}"
|
self._attr_unique_id = f"camera_{self.camera_id}"
|
||||||
self._attr_name = self.device_name
|
self._attr_name = self.device_name
|
||||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
|
||||||
|
|
||||||
self._unsub_stream_refresh: Any = None
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
|
||||||
await super().async_added_to_hass()
|
|
||||||
|
|
||||||
self._unsub_stream_refresh = async_track_time_interval(
|
|
||||||
self.hass,
|
|
||||||
self._stream_refresh,
|
|
||||||
datetime.timedelta(seconds=TOKEN_REFRESH_BUFFER),
|
|
||||||
)
|
|
||||||
_LOGGER.debug(f"Таймер обновления стрима запущен для {self.device_name}")
|
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
|
||||||
if self._unsub_stream_refresh:
|
|
||||||
self._unsub_stream_refresh()
|
|
||||||
self._unsub_stream_refresh = None
|
|
||||||
await super().async_will_remove_from_hass()
|
|
||||||
|
|
||||||
async def _stream_refresh(self, now: datetime.datetime) -> None:
|
|
||||||
try:
|
|
||||||
if self.cameras_api._tokens_invalid:
|
|
||||||
_LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}")
|
|
||||||
await self.cameras_api.refresh_tokens()
|
|
||||||
|
|
||||||
url = await self.stream_source()
|
|
||||||
if url and self.stream and getattr(self.stream, "source", None) != url:
|
|
||||||
_LOGGER.debug(f"Обновляем URL стрима для {self.device_name}")
|
|
||||||
self.stream.update_source(url)
|
|
||||||
except Exception as e:
|
|
||||||
_LOGGER.error(f"Ошибка обновления стрима для {self.camera_id}: {e}")
|
|
||||||
|
|
||||||
async def stream_source(self) -> str | None:
|
async def stream_source(self) -> str | None:
|
||||||
return await self.cameras_api.get_camera_stream_url(self.camera_id)
|
return await self.cameras_api.get_camera_stream_url(self.camera_id)
|
||||||
@@ -113,7 +80,7 @@ class RTKeyCamera(Camera):
|
|||||||
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:
|
||||||
status = "❌ Нет связи"
|
status = "Нет связи"
|
||||||
|
|
||||||
attrs = {"Статус": status}
|
attrs = {"Статус": status}
|
||||||
if self._device_model:
|
if self._device_model:
|
||||||
@@ -141,7 +108,7 @@ class RTKeyCamera(Camera):
|
|||||||
|
|
||||||
|
|
||||||
class RTKeyEventCamera(Camera):
|
class RTKeyEventCamera(Camera):
|
||||||
"""Камера для просмотра архивного видео на момент последнего события (локальный файл)."""
|
"""Архив события: thumbnail-скриншот + ссылка на видео-файл."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
@@ -168,7 +135,6 @@ class RTKeyEventCamera(Camera):
|
|||||||
|
|
||||||
self._attr_unique_id = f"event_camera_{self.intercom_id}"
|
self._attr_unique_id = f"event_camera_{self.intercom_id}"
|
||||||
self._attr_name = "Архив события"
|
self._attr_name = "Архив события"
|
||||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
|
||||||
|
|
||||||
self._event_description = None
|
self._event_description = None
|
||||||
self._event_time = None
|
self._event_time = None
|
||||||
@@ -204,27 +170,22 @@ class RTKeyEventCamera(Camera):
|
|||||||
self._archive_error = None
|
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:
|
|
||||||
_LOGGER.debug(f"Обновляем путь архива для {self.device_name}")
|
|
||||||
# Для локального файла stream не обновляем через update_source
|
|
||||||
# stream_source() будет вызван при запросе
|
|
||||||
|
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
elif archive_error:
|
elif archive_error:
|
||||||
self._archive_error = archive_error
|
self._archive_error = archive_error
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def stream_source(self) -> str | None:
|
async def async_camera_image(
|
||||||
"""Возвращаем путь к локальному файлу."""
|
self, width: int | None = None, height: int | None = None
|
||||||
if self._archive_path and Path(self._archive_path).exists():
|
) -> bytes | None:
|
||||||
return f"file://{self._archive_path}"
|
"""Скриншот камеры как thumbnail для карточки."""
|
||||||
|
if self.camera_id:
|
||||||
|
return await self.cameras_api.get_camera_image(self.camera_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Доступна, если есть локальный файл."""
|
return self.camera_id is not None and self.cameras_api.is_camera_available(self.camera_id)
|
||||||
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]:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
"documentation": "https://github.com/sirmrak/RTKey",
|
"documentation": "https://github.com/sirmrak/RTKey",
|
||||||
"issue_tracker": "https://github.com/sirmrak/RTKey/issues",
|
"issue_tracker": "https://github.com/sirmrak/RTKey/issues",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"version": "1.0.1",
|
"version": "1.2.2",
|
||||||
"requirements": ["transliterate", "pyjwt"],
|
"requirements": [],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"codeowners": ["@sirmrak"]
|
"codeowners": ["@sirmrak"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +18,15 @@
|
|||||||
"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": {
|
||||||
"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_path": "Subfolder name inside HA 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,13 +51,15 @@
|
|||||||
"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": {
|
||||||
"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_path": "Subfolder name inside HA 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,13 +18,15 @@
|
|||||||
"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": {
|
||||||
"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_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
|
||||||
"archive_copies": "Максимальное количество сохранённых видео на домофон",
|
"archive_copies": "Максимальное количество сохранённых видео на домофон",
|
||||||
|
"archive_duration": "Длительность фрагмента видео архива на событие",
|
||||||
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
|
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,13 +51,15 @@
|
|||||||
"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": {
|
||||||
"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_path": "Имя подпапки внутри медиа-папки HA (например: RTKey)",
|
||||||
"archive_copies": "Максимальное количество сохранённых видео на домофон",
|
"archive_copies": "Максимальное количество сохранённых видео на домофон",
|
||||||
|
"archive_duration": "Длительность фрагмента видео архива на событие",
|
||||||
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
|
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user