Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a44a996b1 | ||
|
|
e1f1ff89d8 | ||
|
|
75d985f64a | ||
|
|
8b65cb43eb | ||
|
|
02a3d821e3 | ||
|
|
0e07bc8c9d | ||
|
|
8472b91cd1 |
@@ -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/)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -701,6 +698,10 @@ class RTKeyCamerasApi:
|
|||||||
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) -> str | None:
|
||||||
|
if self._tokens_invalid:
|
||||||
|
_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
|
||||||
|
|
||||||
@@ -789,7 +790,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 +897,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:
|
||||||
|
|||||||
@@ -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, CameraEntityFeature
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.event import async_track_time_interval
|
|
||||||
from homeassistant.helpers.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", []):
|
||||||
@@ -39,6 +38,8 @@ async def async_setup_entry(
|
|||||||
class RTKeyCamera(Camera):
|
class RTKeyCamera(Camera):
|
||||||
"""Live-камера (для всех устройств — и камер, и домофонов)."""
|
"""Live-камера (для всех устройств — и камер, и домофонов)."""
|
||||||
|
|
||||||
|
_attr_supported_features = CameraEntityFeature.STREAM
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -63,38 +64,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 +82,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,10 +110,11 @@ class RTKeyCamera(Camera):
|
|||||||
|
|
||||||
|
|
||||||
class RTKeyEventCamera(Camera):
|
class RTKeyEventCamera(Camera):
|
||||||
"""Камера для просмотра архивного видео на момент последнего события (локальный файл)."""
|
"""Архив события: проигрывание сохранённого видео."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
_attr_supported_features = CameraEntityFeature.STREAM
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -168,7 +138,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,32 +167,34 @@ 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 stream_source(self) -> str | None:
|
||||||
"""Возвращаем путь к локальному файлу."""
|
|
||||||
if self._archive_path and Path(self._archive_path).exists():
|
if self._archive_path and Path(self._archive_path).exists():
|
||||||
return f"file://{self._archive_path}"
|
return f"file://{self._archive_path}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def async_camera_image(
|
||||||
|
self, width: int | None = None, height: int | None = None
|
||||||
|
) -> bytes | None:
|
||||||
|
event_info = self.cameras_api.last_events.get(self.intercom_id)
|
||||||
|
if event_info and event_info.get("screenshot_path"):
|
||||||
|
path = Path(event_info["screenshot_path"])
|
||||||
|
if path.exists():
|
||||||
|
return await self.hass.async_add_executor_job(path.read_bytes)
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Доступна, если есть локальный файл."""
|
|
||||||
return self._archive_path is not None and Path(self._archive_path).exists()
|
return self._archive_path is not None and Path(self._archive_path).exists()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -247,4 +218,4 @@ class RTKeyEventCamera(Camera):
|
|||||||
"name": self.device_name,
|
"name": self.device_name,
|
||||||
"manufacturer": "RT Key",
|
"manufacturer": "RT Key",
|
||||||
"model": "Домофон",
|
"model": "Домофон",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.3",
|
||||||
"requirements": ["transliterate", "pyjwt"],
|
"requirements": [],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"codeowners": ["@sirmrak"]
|
"codeowners": ["@sirmrak"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user