Хранение скриншотов и архивов на диск вместо in-memory

— Замена event_screenshot_enabled/event_archive_enabled на screenshot_copies/archive_copies (0 = отключено)
— Новый параметр archive_path для пути сохранения (относительно /media/)
— Скачивание скриншотов и видео-архивов событий на локальный диск с ограничением копий
— Проверка FFmpeg при инициализации, поддержка file:// для stream_source
— Обновлены переводы (en/ru) и config_flow
This commit is contained in:
2026-06-28 16:09:47 +03:00
parent ddb1253098
commit a12554c010
6 changed files with 335 additions and 140 deletions
+200 -46
View File
@@ -3,11 +3,10 @@ import functools
import logging
import time
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
import requests
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -15,7 +14,6 @@ from homeassistant.helpers import selector
DOMAIN = "rtkey"
PLATFORMS = [Platform.CAMERA, Platform.IMAGE, Platform.BUTTON, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
# === Константы настроек ===
@@ -30,12 +28,14 @@ CONF_EVENTS_ENABLED = "events_enabled"
CONF_EVENTS_REFRESH_INTERVAL = "events_refresh_interval"
CONF_EVENT_TIME_LAG = "event_time_lag"
CONF_PRE_EVENT_SECONDS = "pre_event_seconds"
CONF_EVENT_SCREENSHOT_ENABLED = "event_screenshot_enabled"
CONF_EVENT_SCREENSHOT_QUALITY = "event_screenshot_quality"
CONF_EVENT_ARCHIVE_ENABLED = "event_archive_enabled"
CONF_ARCHIVE_DURATION = "archive_duration"
CONF_EVENT_SENSOR_ENABLED = "event_sensor_enabled"
# Новые настройки для локального хранения
CONF_ARCHIVE_PATH = "archive_path"
CONF_ARCHIVE_COPIES = "archive_copies"
CONF_SCREENSHOT_COPIES = "screenshot_copies"
TOKEN_REFRESH_BUFFER = 300
DATA_SCHEMA = {
@@ -46,7 +46,6 @@ DATA_SCHEMA = {
OPTIONS_SCHEMA = {
vol.Optional(CONF_NAME): str,
vol.Optional(CONF_TOKEN): str,
vol.Optional(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, default=60): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=600, step=30,
@@ -54,7 +53,6 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек",
)
),
vol.Optional(CONF_SCREENSHOT_QUALITY, default="medium"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
@@ -66,7 +64,6 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_LOG_LEVEL, default="INFO"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
@@ -78,9 +75,7 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_EVENTS_ENABLED, default=True): bool,
vol.Optional(CONF_EVENTS_REFRESH_INTERVAL, default=15): selector.NumberSelector(
selector.NumberSelectorConfig(
min=5, max=60, step=5,
@@ -88,7 +83,6 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек",
)
),
vol.Optional(CONF_EVENT_TIME_LAG, default=25): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=60, step=5,
@@ -96,7 +90,6 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек",
)
),
vol.Optional(CONF_PRE_EVENT_SECONDS, default=10): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=30, step=5,
@@ -104,9 +97,6 @@ OPTIONS_SCHEMA = {
unit_of_measurement="сек",
)
),
vol.Optional(CONF_EVENT_SCREENSHOT_ENABLED, default=True): bool,
vol.Optional(CONF_EVENT_SCREENSHOT_QUALITY, default="large"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
@@ -118,17 +108,20 @@ OPTIONS_SCHEMA = {
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_EVENT_ARCHIVE_ENABLED, default=True): bool,
vol.Optional(CONF_ARCHIVE_DURATION, default=30): selector.NumberSelector(
# 🆕 Настройки локального хранения
vol.Optional(CONF_ARCHIVE_PATH, default="media/RTKey"): str,
vol.Optional(CONF_ARCHIVE_COPIES, default=5): selector.NumberSelector(
selector.NumberSelectorConfig(
min=10, max=120, step=10,
mode=selector.NumberSelectorMode.SLIDER,
unit_of_measurement="сек",
min=0, max=10, step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_SCREENSHOT_COPIES, default=10): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=10, step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_EVENT_SENSOR_ENABLED, default=True): bool,
}
@@ -145,12 +138,14 @@ class RTKeyCamerasApi:
self.events_refresh_interval = entry.options.get(CONF_EVENTS_REFRESH_INTERVAL, 15)
self.event_time_lag = entry.options.get(CONF_EVENT_TIME_LAG, 25)
self.pre_event_seconds = entry.options.get(CONF_PRE_EVENT_SECONDS, 10)
self.event_screenshot_enabled = entry.options.get(CONF_EVENT_SCREENSHOT_ENABLED, True)
self.event_screenshot_quality = entry.options.get(CONF_EVENT_SCREENSHOT_QUALITY, "large")
self.event_archive_enabled = entry.options.get(CONF_EVENT_ARCHIVE_ENABLED, True)
self.archive_duration = entry.options.get(CONF_ARCHIVE_DURATION, 30)
self.event_sensor_enabled = entry.options.get(CONF_EVENT_SENSOR_ENABLED, True)
# 🆕 Настройки локального хранения
self.archive_path = entry.options.get(CONF_ARCHIVE_PATH, "media/RTKey")
self.archive_copies = entry.options.get(CONF_ARCHIVE_COPIES, 5)
self.screenshot_copies = entry.options.get(CONF_SCREENSHOT_COPIES, 10)
log_level = entry.options.get(CONF_LOG_LEVEL, "INFO")
_LOGGER.setLevel(getattr(logging, log_level, logging.INFO))
_LOGGER.info(f"📊 Уровень логирования: {log_level}")
@@ -167,10 +162,10 @@ class RTKeyCamerasApi:
self._image_cache = {}
self._image_cache_lock = asyncio.Lock()
# ✅ НОВОЕ: Отслеживание недоступных камер (404)
# Отслеживание недоступных камер (404)
self._unavailable_cameras: set[str] = set()
# ✅ НОВОЕ: Флаг протухших токенов (403)
# Флаг протухших токенов (403)
self._tokens_invalid = False
self._tokens_refresh_lock = asyncio.Lock()
@@ -186,6 +181,156 @@ class RTKeyCamerasApi:
self._local_tz = ZoneInfo(hass.config.time_zone)
_LOGGER.info(f"🕐 Временная зона HA: {hass.config.time_zone}")
# 🆕 Проверка FFmpeg
self.ffmpeg_available = False
async def async_initialize(self):
"""Инициализация после создания (для async операций)."""
await self._check_ffmpeg()
async def _check_ffmpeg(self):
"""Проверяем наличие FFmpeg в системе."""
try:
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
self.ffmpeg_available = True
_LOGGER.info("✅ FFmpeg доступен")
except FileNotFoundError:
self.ffmpeg_available = False
_LOGGER.error("❌ FFmpeg не найден! Архивное видео будет недоступно.")
except Exception as e:
self.ffmpeg_available = False
_LOGGER.error(f"❌ Ошибка проверки FFmpeg: {e}")
def _get_media_base_path(self) -> Path:
"""Получаем базовый путь для медиафайлов."""
# HA хранит медиа в /media/ (media_dirs)
media_dirs = self.hass.config.media_dirs
if media_dirs and "media" in media_dirs:
return Path(media_dirs["media"])
# Fallback
return Path("/media")
def _get_archive_dir(self, intercom_id: str) -> Path:
"""Путь к папке архивов для домофона."""
return self._get_media_base_path() / self.archive_path / "archives" / intercom_id
def _get_screenshot_dir(self, intercom_id: str) -> Path:
"""Путь к папке скриншотов для домофона."""
return self._get_media_base_path() / self.archive_path / "screenshots" / intercom_id
async def download_event_archive(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None:
"""Скачиваем архивное видео через FFmpeg и сохраняем локально."""
if not self.ffmpeg_available:
_LOGGER.error("FFmpeg недоступен, пропускаем скачивание архива")
return None
url = self.get_event_archive_url(camera_id, event_timestamp)
if not url:
_LOGGER.error(f"Не удалось получить URL архива для камеры {camera_id}")
return None
# Создаём папку
archive_dir = self._get_archive_dir(intercom_id)
archive_dir.mkdir(parents=True, exist_ok=True)
# Имя файла
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.mp4")
filepath = archive_dir / filename
# Вычисляем длительность: pre_event + lag + post_event (archive_duration)
# archive_duration по умолчанию 30 сек
archive_duration = 30 # Можно вынести в настройки позже
duration = self.pre_event_seconds + self.event_time_lag + archive_duration
_LOGGER.info(f" Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}")
cmd = [
"ffmpeg", "-y",
"-i", url,
"-t", str(duration),
"-c", "copy",
str(filepath),
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=duration + 30 # Запас на задержки сети
)
if proc.returncode == 0 and filepath.exists() and filepath.stat().st_size > 1000:
_LOGGER.info(f"✅ Архив сохранён: {filepath} ({filepath.stat().st_size} байт)")
self.cleanup_old_archives(intercom_id)
return str(filepath)
else:
error_msg = stderr.decode()[:200] if stderr else "Неизвестная ошибка"
_LOGGER.error(f"❌ Ошибка FFmpeg для {intercom_id}: {error_msg}")
return None
except asyncio.TimeoutError:
_LOGGER.error(f"⏱️ Превышено время скачивания архива для {intercom_id}")
if proc:
proc.kill()
return None
except Exception as e:
_LOGGER.error(f"Ошибка скачивания архива для {intercom_id}: {e}")
return None
async def download_event_screenshot(self, camera_id: str, event_timestamp: int, intercom_id: str) -> str | None:
"""Скачиваем скриншот события и сохраняем локально."""
screenshot_dir = self._get_screenshot_dir(intercom_id)
screenshot_dir.mkdir(parents=True, exist_ok=True)
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.jpg")
filepath = screenshot_dir / filename
# Получаем скриншот через существующий метод
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp)
if screenshot_data:
filepath.write_bytes(screenshot_data)
_LOGGER.info(f"✅ Скриншот сохранён: {filepath} ({len(screenshot_data)} байт)")
self.cleanup_old_screenshots(intercom_id)
return str(filepath)
return None
def cleanup_old_archives(self, intercom_id: str):
"""Удаляем старые архивные видео, если превышено количество копий."""
archive_dir = self._get_archive_dir(intercom_id)
if not archive_dir.exists():
return
files = sorted(archive_dir.glob("*.mp4"), key=lambda f: f.stat().st_mtime)
while len(files) > self.archive_copies:
oldest = files.pop(0)
oldest.unlink()
_LOGGER.debug(f"🗑️ Удалён старый архив: {oldest.name}")
def cleanup_old_screenshots(self, intercom_id: str):
"""Удаляем старые скриншоты, если превышено количество копий."""
screenshot_dir = self._get_screenshot_dir(intercom_id)
if not screenshot_dir.exists():
return
files = sorted(screenshot_dir.glob("*.jpg"), key=lambda f: f.stat().st_mtime)
while len(files) > self.screenshot_copies:
oldest = files.pop(0)
oldest.unlink()
_LOGGER.debug(f"🗑️ Удалён старый скриншот: {oldest.name}")
def build_device_name(self, name: str) -> str:
return name.strip() if name else "RT Key Device"
@@ -269,7 +414,7 @@ class RTKeyCamerasApi:
async def refresh_tokens(self):
"""🔄 Принудительно обновляем все токены (сбрасываем кэш)."""
async with self._tokens_refresh_lock:
_LOGGER.info("🔄 Принудительное обновление токенов...")
_LOGGER.info(" Принудительное обновление токенов...")
self._cameras_cache = None
self._cameras_cache_ts = 0
await self._fetch_cameras()
@@ -338,7 +483,6 @@ class RTKeyCamerasApi:
"serial_number": info["serial_number"],
"mac": info["mac"],
"ip": info["ip"],
# ✅ Добавляем флаг доступности
"available": self.is_camera_available(uid),
})
@@ -349,7 +493,6 @@ class RTKeyCamerasApi:
return await self._fetch_intercoms()
return await self._fetch_intercoms()
async def get_camera_image(self, camera_id: str, retry: bool = False) -> bytes | None:
"""Получаем скриншот камеры с обработкой ошибок 403 и 404."""
async with self._image_cache_lock:
@@ -362,7 +505,6 @@ class RTKeyCamerasApi:
screenshot_token = token_info.get("screenshot_token") if token_info else None
user_token = (token_info.get("user_token") or self.user_token) if token_info else None
# ✅ Получаем название камеры для логов
camera_name = self.get_camera_name(camera_id)
try:
@@ -386,7 +528,6 @@ class RTKeyCamerasApi:
)
)
# ✅ Обработка 403 — токен протух
if r.status_code == 403 and not retry:
_LOGGER.warning(
f"⚠️ Токен для {camera_name} ({camera_id}) протух (403), запускаем обновление..."
@@ -401,7 +542,6 @@ class RTKeyCamerasApi:
)
return None
# ✅ Обработка 404 — камера недоступна
if r.status_code == 404:
if camera_id not in self._unavailable_cameras:
_LOGGER.warning(
@@ -415,7 +555,6 @@ class RTKeyCamerasApi:
async with self._image_cache_lock:
self._image_cache[camera_id] = (image_data, time.time())
# ✅ Если камера была недоступна — помечаем как восстановленную
if camera_id in self._unavailable_cameras:
self._unavailable_cameras.discard(camera_id)
_LOGGER.info(f"✅ Камера \"{camera_name}\" ({camera_id}) снова доступна")
@@ -430,7 +569,6 @@ class RTKeyCamerasApi:
_LOGGER.error(f"Ошибка скриншота {camera_name} ({camera_id}): {e}")
return None
async def get_event_screenshot(self, camera_id: str, event_timestamp: int) -> bytes | None:
"""Получаем скриншот на момент события (с учётом лага)."""
token_info = self.camera_tokens.get(camera_id)
@@ -591,13 +729,25 @@ class RTKeyCamerasApi:
if not event_timestamp:
return
screenshot_data = None
if self.event_screenshot_enabled:
screenshot_data = await self.get_event_screenshot(camera_id, event_timestamp)
# 🆕 Скачиваем скриншот на диск (если включено)
screenshot_path = None
screenshot_error = None
if self.screenshot_copies > 0:
try:
screenshot_path = await self.download_event_screenshot(camera_id, event_timestamp, intercom_id)
except Exception as e:
screenshot_error = str(e)
_LOGGER.error(f"Ошибка скачивания скриншота для {intercom_id}: {e}")
archive_url = None
if self.event_archive_enabled:
archive_url = self.get_event_archive_url(camera_id, event_timestamp)
# 🆕 Скачиваем архив на диск (если включено)
archive_path = None
archive_error = None
if self.archive_copies > 0:
try:
archive_path = await self.download_event_archive(camera_id, event_timestamp, intercom_id)
except Exception as e:
archive_error = str(e)
_LOGGER.error(f"Ошибка скачивания архива для {intercom_id}: {e}")
self.last_events[intercom_id] = {
"event": event,
@@ -606,8 +756,10 @@ class RTKeyCamerasApi:
"local_time": self._format_local_time(event.get("raised_at", "")),
"description": self._get_event_description(event),
"event_type": event.get("event_type", ""),
"screenshot_data": screenshot_data,
"archive_url": archive_url,
"screenshot_path": screenshot_path,
"archive_path": archive_path,
"screenshot_error": screenshot_error,
"archive_error": archive_error,
}
_LOGGER.info(
@@ -668,6 +820,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"cameras_api": cameras_api
}
# 🆕 Инициализация (проверка FFmpeg)
await cameras_api.async_initialize()
entry.add_update_listener(update_listener)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -679,7 +834,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
cameras_api = hass.data[DOMAIN][entry.entry_id]["cameras_api"]
await cameras_api.stop_events_polling()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+32 -18
View File
@@ -1,13 +1,12 @@
import datetime
from pathlib import Path
from typing import Any
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.entity import EntityCategory
from . import _LOGGER, DOMAIN, RTKeyCamerasApi, TOKEN_REFRESH_BUFFER
@@ -24,15 +23,15 @@ async def async_setup_entry(
for camera_info in cameras_info["data"]["items"]
]
# Добавляем архивные камеры событий для домофонов
if cameras_api.event_archive_enabled:
# 🆕 Добавляем архивные камеры только если archive_copies > 0
if cameras_api.archive_copies > 0:
intercoms_info = await cameras_api.get_intercoms_info()
for intercom_info in intercoms_info.get("data", {}).get("devices", []):
entities.append(
RTKeyEventCamera(hass, config_entry, cameras_api, intercom_info)
)
else:
_LOGGER.info("Архивные видео событий отключены")
_LOGGER.info("Архивные видео событий отключены (archive_copies=0)")
async_add_entities(entities)
@@ -86,7 +85,6 @@ class RTKeyCamera(Camera):
async def _stream_refresh(self, now: datetime.datetime) -> None:
try:
# Если токены протухли — обновляем их перед получением URL
if self.cameras_api._tokens_invalid:
_LOGGER.debug(f"Обнаружены протухшие токены, обновляем для {self.device_name}")
await self.cameras_api.refresh_tokens()
@@ -108,12 +106,10 @@ class RTKeyCamera(Camera):
@property
def available(self) -> bool:
"""Камера доступна, если она не в списке недоступных."""
return self.cameras_api.is_camera_available(self.camera_id)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Атрибуты с динамической проверкой доступности камеры."""
if self.cameras_api.is_camera_available(self.camera_id):
status = self._device_status_title
else:
@@ -145,7 +141,7 @@ class RTKeyCamera(Camera):
class RTKeyEventCamera(Camera):
"""Камера для просмотра архивного видео на момент последнего события."""
"""Камера для просмотра архивного видео на момент последнего события (локальный файл)."""
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -176,7 +172,8 @@ class RTKeyEventCamera(Camera):
self._event_description = None
self._event_time = None
self._archive_url = None
self._archive_path = None
self._archive_error = None
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
@@ -199,20 +196,35 @@ class RTKeyEventCamera(Camera):
if not event_info:
return
archive_url = event_info.get("archive_url")
if archive_url:
self._archive_url = archive_url
archive_path = event_info.get("archive_path")
archive_error = event_info.get("archive_error")
if archive_path:
self._archive_path = archive_path
self._archive_error = None
self._event_description = event_info.get("description")
self._event_time = event_info.get("local_time")
if self.stream:
_LOGGER.debug(f"Обновляем URL архива для {self.device_name}")
self.stream.update_source(archive_url)
_LOGGER.debug(f"Обновляем путь архива для {self.device_name}")
# Для локального файла stream не обновляем через update_source
# stream_source() будет вызван при запросе
self.async_write_ha_state()
elif archive_error:
self._archive_error = archive_error
self.async_write_ha_state()
async def stream_source(self) -> str | None:
return self._archive_url
"""Возвращаем путь к локальному файлу."""
if self._archive_path and Path(self._archive_path).exists():
return f"file://{self._archive_path}"
return None
@property
def available(self) -> bool:
"""Доступна, если есть локальный файл."""
return self._archive_path is not None and Path(self._archive_path).exists()
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -221,8 +233,10 @@ class RTKeyEventCamera(Camera):
attrs["Событие"] = self._event_description
if self._event_time:
attrs["Время"] = self._event_time
if self._archive_url:
attrs["URL архива"] = self._archive_url
if self._archive_path:
attrs["Путь к архиву"] = self._archive_path
if self._archive_error:
attrs["Ошибка скачивания"] = self._archive_error
return attrs
@property
+4 -6
View File
@@ -5,7 +5,6 @@ from homeassistant.config_entries import (
OptionsFlowWithConfigEntry,
)
from homeassistant.core import callback
from . import (
CONF_NAME,
CONF_TOKEN,
@@ -16,11 +15,11 @@ from . import (
CONF_EVENTS_REFRESH_INTERVAL,
CONF_EVENT_TIME_LAG,
CONF_PRE_EVENT_SECONDS,
CONF_EVENT_SCREENSHOT_ENABLED,
CONF_EVENT_SCREENSHOT_QUALITY,
CONF_EVENT_ARCHIVE_ENABLED,
CONF_ARCHIVE_DURATION,
CONF_EVENT_SENSOR_ENABLED,
CONF_ARCHIVE_PATH,
CONF_ARCHIVE_COPIES,
CONF_SCREENSHOT_COPIES,
DATA_SCHEMA,
OPTIONS_SCHEMA,
DOMAIN,
@@ -64,11 +63,10 @@ class RTKeyConfigFlow(ConfigFlow, domain=DOMAIN):
options=user_input,
)
# ✅ Добавляем add_suggested_values_to_schema для корректной работы переводов
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(DATA_SCHEMA).extend(OPTIONS_SCHEMA),
{}, # Пустые значения по умолчанию для первой настройки
{},
),
)
+34 -17
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@@ -8,7 +8,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.entity import EntityCategory
from zoneinfo import ZoneInfo
from . import CONF_CAMERA_IMAGE_REFRESH_INTERVAL, DOMAIN, RTKeyCamerasApi, _LOGGER
@@ -20,7 +19,6 @@ async def async_setup_entry(
cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"]
cameras_info = await cameras_api.get_cameras_info()
# Проверяем, включены ли обычные скриншоты
interval = config_entry.options.get(CONF_CAMERA_IMAGE_REFRESH_INTERVAL, 60)
entities = []
@@ -34,15 +32,15 @@ async def async_setup_entry(
else:
_LOGGER.info("Обычные скриншоты отключены (interval=0)")
# Скриншоты событий
if cameras_api.event_screenshot_enabled:
# 🆕 Скриншоты событий только если screenshot_copies > 0
if cameras_api.screenshot_copies > 0:
intercoms_info = await cameras_api.get_intercoms_info()
for intercom_info in intercoms_info.get("data", {}).get("devices", []):
entities.append(
RTKeyEventImage(hass, config_entry, cameras_api, intercom_info)
)
else:
_LOGGER.info("Скриншоты событий отключены")
_LOGGER.info("Скриншоты событий отключены (screenshot_copies=0)")
async_add_entities(entities)
@@ -97,8 +95,6 @@ class RTKeyCameraImageEntity(ImageEntity):
await super().async_will_remove_from_hass()
async def _async_update_image(self, now: datetime | None = None) -> None:
"""Продолжаем опрашивать API даже если камера недоступна,
чтобы узнать, когда она снова появится."""
try:
image = await self.cameras_api.get_camera_image(self.camera_id)
if image:
@@ -114,12 +110,10 @@ class RTKeyCameraImageEntity(ImageEntity):
@property
def available(self) -> bool:
"""Сущность доступна, если камера не в списке недоступных."""
return self.cameras_api.is_camera_available(self.camera_id)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Атрибуты с динамической проверкой доступности камеры."""
if self.cameras_api.is_camera_available(self.camera_id):
status = self._device_status_title
else:
@@ -147,7 +141,7 @@ class RTKeyCameraImageEntity(ImageEntity):
class RTKeyEventImage(ImageEntity):
"""Скриншот на момент последнего события домофона."""
"""Скриншот на момент последнего события домофона (локальный файл)."""
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -176,7 +170,8 @@ class RTKeyEventImage(ImageEntity):
self._attr_name = "Скриншот события"
self._attr_image_last_updated = None
self._cached_image: bytes | None = None
self._screenshot_path = None
self._screenshot_error = None
self._event_description = None
self._event_time = None
@@ -201,13 +196,15 @@ class RTKeyEventImage(ImageEntity):
if not event_info:
return
screenshot_data = event_info.get("screenshot_data")
if screenshot_data:
self._cached_image = screenshot_data
screenshot_path = event_info.get("screenshot_path")
screenshot_error = event_info.get("screenshot_error")
if screenshot_path:
self._screenshot_path = screenshot_path
self._screenshot_error = None
self._event_description = event_info.get("description")
self._event_time = event_info.get("local_time")
# ✅ Используем время события, а не текущее время
event_timestamp = event_info.get("timestamp")
if event_timestamp:
self._attr_image_last_updated = datetime.fromtimestamp(
@@ -219,9 +216,25 @@ class RTKeyEventImage(ImageEntity):
self.async_write_ha_state()
_LOGGER.debug(f"Обновлён скриншот события для {self.device_name}")
elif screenshot_error:
self._screenshot_error = screenshot_error
self.async_write_ha_state()
async def async_image(self) -> bytes | None:
return self._cached_image
"""Читаем скриншот с диска."""
if self._screenshot_path and Path(self._screenshot_path).exists():
try:
return await self.hass.async_add_executor_job(
Path(self._screenshot_path).read_bytes
)
except Exception as e:
_LOGGER.error(f"Ошибка чтения скриншота {self._screenshot_path}: {e}")
return None
@property
def available(self) -> bool:
"""Доступна, если есть локальный файл."""
return self._screenshot_path is not None and Path(self._screenshot_path).exists()
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -230,6 +243,10 @@ class RTKeyEventImage(ImageEntity):
attrs["Событие"] = self._event_description
if self._event_time:
attrs["Время"] = self._event_time
if self._screenshot_path:
attrs["Путь к скриншоту"] = self._screenshot_path
if self._screenshot_error:
attrs["Ошибка скачивания"] = self._screenshot_error
return attrs
@property
+16 -10
View File
@@ -14,15 +14,18 @@
"events_refresh_interval": "Event polling interval (sec)",
"event_time_lag": "Camera time lag (sec)",
"pre_event_seconds": "Seconds before event to show",
"event_screenshot_enabled": "Create event screenshot entity",
"event_screenshot_quality": "Event screenshot quality",
"event_archive_enabled": "Create event archive camera",
"archive_duration": "Archive duration (sec)",
"event_sensor_enabled": "Create event sensor entity"
"event_sensor_enabled": "Create event sensor entity",
"archive_path": "Archive storage path",
"archive_copies": "Number of video copies (0 = disabled)",
"screenshot_copies": "Number of screenshot copies (0 = disabled)"
},
"data_description": {
"token": "Get token via browser developer tools (F12) on key.rt.ru",
"camera_image_refresh_interval": "0 — disable periodic screenshots"
"camera_image_refresh_interval": "0 — disable periodic screenshots",
"archive_path": "Relative to /media/ folder (e.g.: RTKey)",
"archive_copies": "Maximum number of saved videos per intercom",
"screenshot_copies": "Maximum number of saved screenshots per intercom"
}
}
}
@@ -42,15 +45,18 @@
"events_refresh_interval": "Event polling interval (sec)",
"event_time_lag": "Camera time lag (sec)",
"pre_event_seconds": "Seconds before event to show",
"event_screenshot_enabled": "Create event screenshot entity",
"event_screenshot_quality": "Event screenshot quality",
"event_archive_enabled": "Create event archive camera",
"archive_duration": "Archive duration (sec)",
"event_sensor_enabled": "Create event sensor entity"
"event_sensor_enabled": "Create event sensor entity",
"archive_path": "Archive storage path",
"archive_copies": "Number of video copies (0 = disabled)",
"screenshot_copies": "Number of screenshot copies (0 = disabled)"
},
"data_description": {
"token": "Get token via browser developer tools (F12) on key.rt.ru",
"camera_image_refresh_interval": "0 — disable periodic screenshots"
"camera_image_refresh_interval": "0 — disable periodic screenshots",
"archive_path": "Relative to /media/ folder (e.g.: RTKey)",
"archive_copies": "Maximum number of saved videos per intercom",
"screenshot_copies": "Maximum number of saved screenshots per intercom"
}
}
}
+16 -10
View File
@@ -14,15 +14,18 @@
"events_refresh_interval": "Интервал опроса событий (сек)",
"event_time_lag": "Задержка времени камеры (сек)",
"pre_event_seconds": "Секунд до события для показа",
"event_screenshot_enabled": "Создавать сущность скриншота события",
"event_screenshot_quality": "Качество скриншота события",
"event_archive_enabled": "Создавать камеру архива событий",
"archive_duration": "Длительность архива (сек)",
"event_sensor_enabled": "Создавать сенсор последнего события"
"event_sensor_enabled": "Создавать сенсор последнего события",
"archive_path": "Путь для сохранения архивов",
"archive_copies": "Количество копий видео (0 = отключено)",
"screenshot_copies": "Количество копий скриншотов (0 = отключено)"
},
"data_description": {
"token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru",
"camera_image_refresh_interval": "0 — отключить периодические скриншоты"
"camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Относительно папки /media/ (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
}
}
}
@@ -42,15 +45,18 @@
"events_refresh_interval": "Интервал опроса событий (сек)",
"event_time_lag": "Задержка времени камеры (сек)",
"pre_event_seconds": "Секунд до события для показа",
"event_screenshot_enabled": "Создавать сущность скриншота события",
"event_screenshot_quality": "Качество скриншота события",
"event_archive_enabled": "Создавать камеру архива событий",
"archive_duration": "Длительность архива (сек)",
"event_sensor_enabled": "Создавать сенсор последнего события"
"event_sensor_enabled": "Создавать сенсор последнего события",
"archive_path": "Путь для сохранения архивов",
"archive_copies": "Количество копий видео (0 = отключено)",
"screenshot_copies": "Количество копий скриншотов (0 = отключено)"
},
"data_description": {
"token": "Получите токен в браузере через инструменты разработчика (F12) на сайте key.rt.ru",
"camera_image_refresh_interval": "0 — отключить периодические скриншоты"
"camera_image_refresh_interval": "0 — отключить периодические скриншоты",
"archive_path": "Относительно папки /media/ (например: RTKey)",
"archive_copies": "Максимальное количество сохранённых видео на домофон",
"screenshot_copies": "Максимальное количество сохранённых скриншотов на домофон"
}
}
}