Критические баг-фиксы и подготовка к релизу 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)
This commit is contained in:
2026-06-28 19:33:33 +03:00
parent ff5406828c
commit 8472b91cd1
2 changed files with 40 additions and 52 deletions
+38 -50
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,7 +298,7 @@ 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")
@@ -366,7 +354,7 @@ 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")
@@ -385,7 +373,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 +411,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 +459,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 +517,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 +619,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 +660,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
@@ -789,7 +778,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 +885,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:
+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.1.0",
"requirements": ["transliterate", "pyjwt"], "requirements": [],
"config_flow": true, "config_flow": true,
"codeowners": ["@sirmrak"] "codeowners": ["@sirmrak"]
} }