6 Commits
Author SHA1 Message Date
sirmrak e1f1ff89d8 Новая версия 2026-06-29 12:31:51 +03:00
sirmrakandQwen-Coder 75d985f64a Обновление токенов стрима по 403 вместо периодического таймера
— Убран async_track_time_interval из RTKeyCamera (NameError + лишняя нагрузка)
— get_camera_stream_url: проверка 403 с retry, как в get_camera_image
— Токены обновляются только по факту ошибки, не каждые 300 сек

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-29 11:57:54 +03:00
sirmrak 8b65cb43eb Новая версия 2026-06-29 11:35:48 +03:00
sirmrak 02a3d821e3 Пропуск повторной загрузки существующих архивов/скриншотов, иконка в README
— archive/screenshot: проверка exists+st_size>0 перед скачиванием
— README: добавлена иконка интеграции в заголовок
2026-06-29 11:19:58 +03:00
sirmrak 0e07bc8c9d Длительность архива = archive_duration, Camera без STREAM для архивов
— duration = archive_duration (не сумма lag+pre+archive)
— RTKeyEventCamera: убран STREAM (HLS обрезает видео + reconnect loop)
— RTKeyEventCamera: async_camera_image() для thumbnail (fix NotImplementedError)
— Убраны эмодзи из статусов, убран CameraEntityFeature импорт из EventCamera
2026-06-28 22:33:17 +03:00
sirmrak 8472b91cd1 Критические баг-фиксы и подготовка к релизу v1.1.0
— _ApiResponse dataclass: body читается внутри async with (Connection closed fix)
— _mkdir_parents helper: keyword args вместо positional (FileExistsError fix)
— async_get_clientsession(hass): HA managed session, убран self._session/_close_session
— manifest.json: version 1.1.0, requirements [] (убраны transliterate, pyjwt)
2026-06-28 19:33:33 +03:00
4 changed files with 92 additions and 109 deletions
+1
View File
@@ -1,3 +1,4 @@
<img src="custom_components/rtkey/icon.png" width="128"> </img>
# RT Key Integration for Home Assistant
[![Home Assistant](https://img.shields.io/badge/Home%20Assistant-Integration-blue)](https://www.home-assistant.io/)
+73 -52
View File
@@ -1,7 +1,8 @@
import asyncio
import json
import logging
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
@@ -12,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from yarl import URL
DOMAIN = "rtkey"
@@ -153,6 +155,12 @@ class CameraTokenInfo:
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",
@@ -161,6 +169,11 @@ _DEFAULT_HEADERS = {
}
def _mkdir_parents(p: Path):
"""Path.mkdir с keyword args для executor."""
p.mkdir(parents=True, exist_ok=True)
class RTKeyCamerasApi:
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
self.hass = hass
@@ -188,8 +201,8 @@ class RTKeyCamerasApi:
self.user_token: str | None = None
# aiohttp session
self._session: aiohttp.ClientSession | None = None
# HA managed aiohttp session
self._session: aiohttp.ClientSession = async_get_clientsession(hass)
# Информация о камерах
self.camera_tokens: dict[str, CameraTokenInfo] = {}
@@ -221,35 +234,22 @@ class RTKeyCamerasApi:
self.ffmpeg_available = False
def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def async_initialize(self):
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",
]
dirs_to_create = [base / "archives", base / "screenshots"]
for dir_path in dirs_to_create:
await self.hass.async_add_executor_job(dir_path.mkdir, True, True)
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(dir_path.mkdir, True, True)
await self.hass.async_add_executor_job(_mkdir_parents, dir_path)
async def _check_ffmpeg(self):
try:
@@ -268,27 +268,15 @@ class RTKeyCamerasApi:
self.ffmpeg_available = False
_LOGGER.error(f"Ошибка проверки FFmpeg: {e}")
async def _close_session(self):
if self._session and not self._session.closed:
await self._session.close()
self._session = None
# === Медиа-пути ===
def _get_media_base_path(self) -> Path:
"""Получаем базовый путь для медиафайлов.
HA default media folder — hass.config.path("media").
Если user configured media_dirs в configuration.yaml — используем
ключ 'media' (default) или 'local' если 'media' отсутствует.
"""
media_dirs = self.hass.config.media_dirs
if media_dirs:
if "media" in media_dirs:
return Path(media_dirs["media"])
if "local" in media_dirs:
return Path(media_dirs["local"])
return Path(self.hass.config.path("media"))
def _get_archive_dir(self, intercom_id: str) -> Path:
@@ -310,13 +298,17 @@ class RTKeyCamerasApi:
return None
archive_dir = self._get_archive_dir(intercom_id)
await self.hass.async_add_executor_job(archive_dir.mkdir, True, True)
await self.hass.async_add_executor_job(_mkdir_parents, archive_dir)
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.mp4")
filepath = archive_dir / filename
duration = self.pre_event_seconds + self.event_time_lag + self.archive_duration
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)
duration = self.archive_duration
_LOGGER.info(f"Скачиваем архив для {intercom_id}: {duration} сек, файл: {filename}")
@@ -366,12 +358,16 @@ class RTKeyCamerasApi:
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)
await self.hass.async_add_executor_job(screenshot_dir.mkdir, True, True)
await self.hass.async_add_executor_job(_mkdir_parents, screenshot_dir)
dt = datetime.fromtimestamp(event_timestamp, tz=self._local_tz)
filename = dt.strftime("%Y-%m-%d_%H-%M-%S.jpg")
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)
if screenshot_data:
@@ -385,7 +381,6 @@ class RTKeyCamerasApi:
return None
async def cleanup_old_files(self, directory: Path, pattern: str, max_copies: int):
"""Удаляем старые файлы, если превышено количество копий (async via executor)."""
def _cleanup():
if not directory.exists():
return
@@ -424,31 +419,33 @@ class RTKeyCamerasApi:
except Exception as e:
_LOGGER.error(f"Ошибка в event listener: {e}")
# === HTTP API (aiohttp) ===
# === HTTP API (aiohttp, HA managed session) ===
async def _api_get(self, url: str | URL, headers: dict, timeout: int = 20) -> aiohttp.ClientResponse | None:
session = self._get_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 session.get(
async with self._session.get(
url,
headers=headers,
timeout=aiohttp.ClientTimeout(total=timeout),
) as r:
return 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: dict | None = None, timeout: int = 10) -> aiohttp.ClientResponse | None:
session = self._get_session()
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 session.post(
async with self._session.post(
url,
headers=headers,
json=json or {},
json=json_data or {},
timeout=aiohttp.ClientTimeout(total=timeout),
) as r:
return 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
@@ -470,7 +467,7 @@ class RTKeyCamerasApi:
return []
if r.status == 200:
data = await r.json()
data = json.loads(r.body)
devices = data.get("data", [])
if devices:
@@ -528,7 +525,7 @@ class RTKeyCamerasApi:
return {"data": {"devices": []}}
if r.status == 200:
data = await r.json()
data = json.loads(r.body)
self._build_intercom_mappings(data)
devices = data.get("data", {}).get("devices", [])
@@ -630,7 +627,7 @@ class RTKeyCamerasApi:
return None
if r.status == 200:
image_data = await r.read()
image_data = r.body
if len(image_data) > 2000:
async with self._image_cache_lock:
self._image_cache[camera_id] = (image_data, time.time())
@@ -671,7 +668,7 @@ class RTKeyCamerasApi:
return None
if r.status == 200:
content = await r.read()
content = r.body
if len(content) > 1000:
_LOGGER.info(f"Скриншот события получен {camera_id} ({len(content)} байт)")
return content
@@ -700,7 +697,11 @@ class RTKeyCamerasApi:
url = url.update_query({"token": streamer_token})
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:
if self._tokens_invalid and not retry:
_LOGGER.debug(f"Токены протухшие, обновляем перед запросом стрима для {camera_id}")
await self.refresh_tokens()
info = self.camera_tokens.get(camera_id)
streamer_token = info.streamer_token if info else None
@@ -711,6 +712,27 @@ class RTKeyCamerasApi:
})
if streamer_token:
url = url.update_query({"token": streamer_token})
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)
# === Домофон: открытие ===
@@ -789,7 +811,7 @@ class RTKeyCamerasApi:
return None
if r.status == 200:
data = await r.json()
data = json.loads(r.body)
items = data.get("data", {}).get("items", [])
if items:
return items[0]
@@ -896,7 +918,6 @@ class RTKeyCamerasApi:
except asyncio.CancelledError:
pass
self._events_polling_task = None
await self._close_session()
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+13 -52
View File
@@ -1,13 +1,13 @@
import datetime
from pathlib import Path
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.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
from . import _LOGGER, DOMAIN, RTKeyCamerasApi
async def async_setup_entry(
@@ -23,7 +23,6 @@ async def async_setup_entry(
for camera_info in cameras_info["data"]["items"]
]
# 🆕 Добавляем архивные камеры только если 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", []):
@@ -63,38 +62,6 @@ class RTKeyCamera(Camera):
self._attr_unique_id = f"camera_{self.camera_id}"
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:
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):
status = self._device_status_title
else:
status = "❌ Нет связи"
status = "Нет связи"
attrs = {"Статус": status}
if self._device_model:
@@ -141,7 +108,7 @@ class RTKeyCamera(Camera):
class RTKeyEventCamera(Camera):
"""Камера для просмотра архивного видео на момент последнего события (локальный файл)."""
"""Архив события: thumbnail-скриншот + ссылка на видео-файл."""
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -168,7 +135,6 @@ class RTKeyEventCamera(Camera):
self._attr_unique_id = f"event_camera_{self.intercom_id}"
self._attr_name = "Архив события"
self._attr_supported_features = CameraEntityFeature.STREAM
self._event_description = None
self._event_time = None
@@ -204,27 +170,22 @@ class RTKeyEventCamera(Camera):
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"Обновляем путь архива для {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:
"""Возвращаем путь к локальному файлу."""
if self._archive_path and Path(self._archive_path).exists():
return f"file://{self._archive_path}"
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Скриншот камеры как thumbnail для карточки."""
if self.camera_id:
return await self.cameras_api.get_camera_image(self.camera_id)
return None
@property
def available(self) -> bool:
"""Доступна, если есть локальный файл."""
return self._archive_path is not None and Path(self._archive_path).exists()
return self.camera_id is not None and self.cameras_api.is_camera_available(self.camera_id)
@property
def extra_state_attributes(self) -> dict[str, Any]:
+2 -2
View File
@@ -4,8 +4,8 @@
"documentation": "https://github.com/sirmrak/RTKey",
"issue_tracker": "https://github.com/sirmrak/RTKey/issues",
"iot_class": "cloud_polling",
"version": "1.0.1",
"requirements": ["transliterate", "pyjwt"],
"version": "1.2.2",
"requirements": [],
"config_flow": true,
"codeowners": ["@sirmrak"]
}