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 # RT Key Integration for Home Assistant
[![Home Assistant](https://img.shields.io/badge/Home%20Assistant-Integration-blue)](https://www.home-assistant.io/) [![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 asyncio
import json
import logging import logging
import time import time
from dataclasses import dataclass, field 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
@@ -12,6 +13,7 @@ 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 from yarl import URL
DOMAIN = "rtkey" DOMAIN = "rtkey"
@@ -153,6 +155,12 @@ class CameraTokenInfo:
user_token: str | None = None user_token: str | None = None
@dataclass
class _ApiResponse:
status: int
body: bytes
_DEFAULT_HEADERS = { _DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "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", "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: class RTKeyCamerasApi:
def __init__(self, hass: HomeAssistant, entry: ConfigEntry): def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
self.hass = hass self.hass = hass
@@ -188,8 +201,8 @@ class RTKeyCamerasApi:
self.user_token: str | None = None self.user_token: str | None = None
# aiohttp session # HA managed aiohttp session
self._session: aiohttp.ClientSession | None = None self._session: aiohttp.ClientSession = async_get_clientsession(hass)
# Информация о камерах # Информация о камерах
self.camera_tokens: dict[str, CameraTokenInfo] = {} self.camera_tokens: dict[str, CameraTokenInfo] = {}
@@ -221,35 +234,22 @@ class RTKeyCamerasApi:
self.ffmpeg_available = False self.ffmpeg_available = False
def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def async_initialize(self): async def async_initialize(self):
await self._check_ffmpeg() await self._check_ffmpeg()
await self._ensure_media_dirs() await self._ensure_media_dirs()
async def _ensure_media_dirs(self): async def _ensure_media_dirs(self):
"""Создаём базовую структуру папок для архивов и скриншотов."""
base = self._get_media_base_path() / self.archive_path 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: 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"Медиа-папка: {dir_path}")
_LOGGER.info(f"Базовый путь для медиа: {base}") _LOGGER.info(f"Базовый путь для медиа: {base}")
async def _ensure_intercom_dirs(self, intercom_ids: list[str]): async def _ensure_intercom_dirs(self, intercom_ids: list[str]):
"""Создаём подпапки для каждого домофона после загрузки их списка."""
for intercom_id in intercom_ids: for intercom_id in intercom_ids:
for dir_path in (self._get_archive_dir(intercom_id), self._get_screenshot_dir(intercom_id)): 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): async def _check_ffmpeg(self):
try: try:
@@ -268,27 +268,15 @@ class RTKeyCamerasApi:
self.ffmpeg_available = False self.ffmpeg_available = False
_LOGGER.error(f"Ошибка проверки FFmpeg: {e}") _LOGGER.error(f"Ошибка проверки FFmpeg: {e}")
async def _close_session(self):
if self._session and not self._session.closed:
await self._session.close()
self._session = None
# === Медиа-пути === # === Медиа-пути ===
def _get_media_base_path(self) -> Path: def _get_media_base_path(self) -> Path:
"""Получаем базовый путь для медиафайлов.
HA default media folder — hass.config.path("media").
Если user configured media_dirs в configuration.yaml — используем
ключ 'media' (default) или 'local' если 'media' отсутствует.
"""
media_dirs = self.hass.config.media_dirs media_dirs = self.hass.config.media_dirs
if media_dirs: if media_dirs:
if "media" in media_dirs: if "media" in media_dirs:
return Path(media_dirs["media"]) return Path(media_dirs["media"])
if "local" in media_dirs: if "local" in media_dirs:
return Path(media_dirs["local"]) return Path(media_dirs["local"])
return Path(self.hass.config.path("media")) 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:
@@ -310,13 +298,17 @@ class RTKeyCamerasApi:
return None return None
archive_dir = self._get_archive_dir(intercom_id) 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) 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
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}") _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: 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)
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) 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:
@@ -385,7 +381,6 @@ class RTKeyCamerasApi:
return None return None
async def cleanup_old_files(self, directory: Path, pattern: str, max_copies: int): async def cleanup_old_files(self, directory: Path, pattern: str, max_copies: int):
"""Удаляем старые файлы, если превышено количество копий (async via executor)."""
def _cleanup(): def _cleanup():
if not directory.exists(): if not directory.exists():
return return
@@ -424,31 +419,33 @@ 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) === # === HTTP API (aiohttp, HA managed session) ===
async def _api_get(self, url: str | URL, headers: dict, timeout: int = 20) -> aiohttp.ClientResponse | None: async def _api_get(self, url: str | URL, headers: dict, timeout: int = 20) -> _ApiResponse | None:
session = self._get_session() """GET-запрос. Читает body внутри context manager, возвращает status + bytes."""
try: try:
async with session.get( async with self._session.get(
url, url,
headers=headers, headers=headers,
timeout=aiohttp.ClientTimeout(total=timeout), timeout=aiohttp.ClientTimeout(total=timeout),
) as r: ) as r:
return r body = await r.read()
return _ApiResponse(status=r.status, body=body)
except (aiohttp.ClientError, asyncio.TimeoutError) as e: except (aiohttp.ClientError, asyncio.TimeoutError) as e:
_LOGGER.error(f"HTTP ошибка GET {url}: {e}") _LOGGER.error(f"HTTP ошибка GET {url}: {e}")
return None return None
async def _api_post(self, url: str | URL, headers: dict, json: dict | None = None, timeout: int = 10) -> aiohttp.ClientResponse | None: async def _api_post(self, url: str | URL, headers: dict, json_data: dict | None = None, timeout: int = 10) -> _ApiResponse | None:
session = self._get_session() """POST-запрос. Читает body внутри context manager."""
try: try:
async with session.post( async with self._session.post(
url, url,
headers=headers, headers=headers,
json=json or {}, json=json_data or {},
timeout=aiohttp.ClientTimeout(total=timeout), timeout=aiohttp.ClientTimeout(total=timeout),
) as r: ) as r:
return r body = await r.read()
return _ApiResponse(status=r.status, body=body)
except (aiohttp.ClientError, asyncio.TimeoutError) as e: except (aiohttp.ClientError, asyncio.TimeoutError) as e:
_LOGGER.error(f"HTTP ошибка POST {url}: {e}") _LOGGER.error(f"HTTP ошибка POST {url}: {e}")
return None return None
@@ -470,7 +467,7 @@ class RTKeyCamerasApi:
return [] return []
if r.status == 200: if r.status == 200:
data = await r.json() data = json.loads(r.body)
devices = data.get("data", []) devices = data.get("data", [])
if devices: if devices:
@@ -528,7 +525,7 @@ class RTKeyCamerasApi:
return {"data": {"devices": []}} return {"data": {"devices": []}}
if r.status == 200: if r.status == 200:
data = await r.json() 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", [])
@@ -630,7 +627,7 @@ class RTKeyCamerasApi:
return None return None
if r.status == 200: if r.status == 200:
image_data = await r.read() image_data = r.body
if len(image_data) > 2000: 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())
@@ -671,7 +668,7 @@ class RTKeyCamerasApi:
return None return None
if r.status == 200: if r.status == 200:
content = await r.read() content = r.body
if len(content) > 1000: if len(content) > 1000:
_LOGGER.info(f"Скриншот события получен {camera_id} ({len(content)} байт)") _LOGGER.info(f"Скриншот события получен {camera_id} ({len(content)} байт)")
return content return content
@@ -700,7 +697,11 @@ class RTKeyCamerasApi:
url = url.update_query({"token": streamer_token}) url = url.update_query({"token": streamer_token})
return str(url) 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) info = self.camera_tokens.get(camera_id)
streamer_token = info.streamer_token if info else None streamer_token = info.streamer_token if info else None
@@ -711,6 +712,27 @@ class RTKeyCamerasApi:
}) })
if streamer_token: if streamer_token:
url = url.update_query({"token": 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) return str(url)
# === Домофон: открытие === # === Домофон: открытие ===
@@ -789,7 +811,7 @@ class RTKeyCamerasApi:
return None return None
if r.status == 200: if r.status == 200:
data = await 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]
@@ -896,7 +918,6 @@ class RTKeyCamerasApi:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
self._events_polling_task = None self._events_polling_task = None
await self._close_session()
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+16 -55
View File
@@ -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(
@@ -17,13 +17,12 @@ async def async_setup_entry(
) -> None: ) -> None:
cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"] cameras_api: RTKeyCamerasApi = hass.data[DOMAIN][config_entry.entry_id]["cameras_api"]
cameras_info = await cameras_api.get_cameras_info() cameras_info = await cameras_api.get_cameras_info()
entities = [ entities = [
RTKeyCamera(hass, config_entry, cameras_api, camera_info) RTKeyCamera(hass, config_entry, cameras_api, camera_info)
for camera_info in cameras_info["data"]["items"] for camera_info in cameras_info["data"]["items"]
] ]
# 🆕 Добавляем архивные камеры только если archive_copies > 0
if cameras_api.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
@@ -198,33 +164,28 @@ class RTKeyEventCamera(Camera):
archive_path = event_info.get("archive_path") archive_path = event_info.get("archive_path")
archive_error = event_info.get("archive_error") archive_error = event_info.get("archive_error")
if archive_path: if archive_path:
self._archive_path = archive_path self._archive_path = archive_path
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]:
@@ -247,4 +208,4 @@ class RTKeyEventCamera(Camera):
"name": self.device_name, "name": self.device_name,
"manufacturer": "RT Key", "manufacturer": "RT Key",
"model": "Домофон", "model": "Домофон",
} }
+2 -2
View File
@@ -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"]
} }