1 Commits
Author SHA1 Message Date
sirmrak 5016165c10 Update .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 5s
2026-07-24 13:57:05 +03:00
2 changed files with 182 additions and 261 deletions
+1 -1
View File
@@ -5,6 +5,7 @@ on:
branches: branches:
- main - main
- master - master
- dev
jobs: jobs:
update-version: update-version:
@@ -42,5 +43,4 @@ jobs:
unset GITHUB_SERVER_URL unset GITHUB_SERVER_URL
unset GITHUB_API_URL unset GITHUB_API_URL
unset GITHUB_REPOSITORY unset GITHUB_REPOSITORY
semantic-release version semantic-release version
+176 -255
View File
@@ -1,15 +1,11 @@
"""Coordinator for Motion Recorder.""" """Coordinator for Motion Recorder."""
import asyncio import asyncio
import logging import logging
from datetime import timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.event import ( from homeassistant.helpers.event import async_track_state_change_event, async_call_later, async_track_time_change
async_track_state_change_event,
async_call_later,
async_track_time_change,
)
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import ( from .const import (
@@ -43,36 +39,26 @@ try:
except ImportError: except ImportError:
_RECORDER_PROVIDER = "recorder" _RECORDER_PROVIDER = "recorder"
# Порог валидности файла записи (байт)
_MIN_RECORDING_SIZE = 1024 _MIN_RECORDING_SIZE = 1024
# Таймауты и параметры вынесены из тела методов для удобства тюнинга
_STREAM_CREATE_TIMEOUT = 30.0 # Создание стрима (камера может быть недоступна)
_RECORD_STOP_TIMEOUT = 10.0 # Ожидание штатного завершения задачи записи
_TASK_CANCEL_TIMEOUT = 5.0 # Ожидание после cancel зависшей задачи
_ERROR_RECOVERY_DELAY = 5 # Пауза перед возвратом из состояния error
_FINALIZE_POLL_STEP = 0.2 # Шаг опроса файла при финализации
_FINALIZE_POLL_TRIES = 10 # Макс. число попыток (итого ~2 сек)
_CLEANUP_HOUR = 3 # Час ежедневной очистки
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _get_config_value(entry, key, default=None): def _get_config_value(entry, key, default=None):
"""Значение настройки: сначала options, потом data, потом default.""" """Get config value from options first, then from data, then default."""
return entry.options.get(key, entry.data.get(key, default)) return entry.options.get(key, entry.data.get(key, default))
def _mkdir_parents(p: Path): def _mkdir_parents(p: Path):
"""Path.mkdir с keyword-аргументами для executor.""" """Path.mkdir с keyword args для executor."""
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
class MotionRecorderCoordinator(DataUpdateCoordinator): class MotionRecorderCoordinator(DataUpdateCoordinator):
"""Координатор Motion Recorder.""" """Motion Recorder coordinator."""
def __init__(self, hass: HomeAssistant, entry) -> None: def __init__(self, hass: HomeAssistant, entry) -> None:
"""Инициализация координатора.""" """Initialize coordinator."""
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
@@ -82,11 +68,9 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self.entry = entry self.entry = entry
self.camera_entity = entry.data[CONF_CAMERA_ENTITY] self.camera_entity = entry.data[CONF_CAMERA_ENTITY]
# Настройки: options с fallback на data # Читаем настройки из options с fallback на data
self.save_path = _get_config_value(entry, CONF_SAVE_PATH, "{camera_name}") self.save_path = _get_config_value(entry, CONF_SAVE_PATH, "{camera_name}")
self.filename_template = _get_config_value( self.filename_template = _get_config_value(entry, CONF_FILENAME_TEMPLATE, "%d-%m-%Y_%H-%M-%S")
entry, CONF_FILENAME_TEMPLATE, "%d-%m-%Y_%H-%M-%S"
)
self.max_duration = _get_config_value(entry, CONF_MAX_DURATION, 30) self.max_duration = _get_config_value(entry, CONF_MAX_DURATION, 30)
self.prebuffer = _get_config_value(entry, CONF_PREBUFFER, 0) self.prebuffer = _get_config_value(entry, CONF_PREBUFFER, 0)
self.motion_sensors = _get_config_value(entry, CONF_MOTION_SENSORS, []) self.motion_sensors = _get_config_value(entry, CONF_MOTION_SENSORS, [])
@@ -94,52 +78,44 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self.off_delay = _get_config_value(entry, CONF_OFF_DELAY, 0) self.off_delay = _get_config_value(entry, CONF_OFF_DELAY, 0)
self.force_stop_sensor = _get_config_value(entry, CONF_FORCE_STOP_SENSOR) self.force_stop_sensor = _get_config_value(entry, CONF_FORCE_STOP_SENSOR)
self.force_stop_state = _get_config_value(entry, CONF_FORCE_STOP_STATE, "off") self.force_stop_state = _get_config_value(entry, CONF_FORCE_STOP_STATE, "off")
self.retention_days = _get_config_value( self.retention_days = _get_config_value(entry, CONF_RETENTION_DAYS, DEFAULT_RETENTION_DAYS)
entry, CONF_RETENTION_DAYS, DEFAULT_RETENTION_DAYS
)
# Управляемые устройства # Управляемые устройства
self.controlled_entities = _get_config_value(entry, CONF_CONTROLLED_ENTITIES, []) self.controlled_entities = _get_config_value(entry, CONF_CONTROLLED_ENTITIES, [])
self.control_states = _get_config_value( self.control_states = _get_config_value(entry, CONF_CONTROL_STATES, DEFAULT_CONTROL_STATES)
entry, CONF_CONTROL_STATES, DEFAULT_CONTROL_STATES
)
self._we_turned_on: set[str] = set() self._we_turned_on: set[str] = set()
# Кэш логического флага «устройства должны быть включены»,
# чтобы не опрашивать состояния при каждом переходе состояния
self._control_should_be_on: bool | None = None
# Состояние # State management
self._state = STATE_IDLE self._state = STATE_IDLE
self._attributes: dict = {} self._attributes = {}
# Флаги разрешения/блокировки записи # Enabled state
self._enabled = True self._enabled = True
self._force_stopped = False self._force_stopped = False
# Подписки # Listeners
self._unsub_motion_listeners: list = [] self._unsub_motion_listeners = []
self._unsub_force_stop_listener = None self._unsub_force_stop_listener = None
self._cleanup_unsub = None self._cleanup_unsub = None
# Таймеры # Timers
self._motion_detect_timer = None self._motion_detect_timer = None
self._off_delay_timer = None self._off_delay_timer = None
# Текущая запись # Recording
self._current_stream = None self._current_stream = None
self._current_record_task = None self._current_record_task = None
self._recording_start_time = None self._recording_start_time = None
self._current_filename = None self._current_filename = None
# Статистика # Statistics
self._recordings_count = 0 self._recordings_count = 0
self._total_duration = 0 self._total_duration = 0
self._total_size = 0 self._total_size = 0
# === Пути ===
def _get_media_base_path(self) -> Path: def _get_media_base_path(self) -> Path:
"""Базовый путь к медиа.""" """Получить базовый путь к медиа."""
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:
@@ -151,23 +127,23 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
return Path(self.hass.config.path("media")) return Path(self.hass.config.path("media"))
def _get_save_dir(self) -> Path: def _get_save_dir(self) -> Path:
"""Полная папка для сохранения записей.""" """Получить полную папку для сохранения записей."""
camera_name = self.camera_entity.split(".")[1] camera_name = self.camera_entity.split(".")[1]
save_path = self.save_path.format(camera_name=camera_name) save_path = self.save_path.format(camera_name=camera_name)
if Path(save_path).is_absolute(): if Path(save_path).is_absolute():
return Path(save_path) return Path(save_path)
return self._get_media_base_path() / save_path
# === Свойства состояния === return self._get_media_base_path() / save_path
@property @property
def state(self): def state(self):
"""Текущее состояние.""" """Return current state."""
return self._state return self._state
@property @property
def attributes(self): def attributes(self):
"""Атрибуты со статистикой.""" """Return current attributes with statistics."""
attrs = self._attributes.copy() attrs = self._attributes.copy()
attrs["recordings_count"] = self._recordings_count attrs["recordings_count"] = self._recordings_count
attrs["total_duration"] = self._total_duration attrs["total_duration"] = self._total_duration
@@ -177,66 +153,35 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
@property @property
def is_enabled(self) -> bool: def is_enabled(self) -> bool:
"""Разрешена ли запись.""" """Return if recording is enabled."""
return self._enabled return self._enabled
# === Хелперы блокировок ===
def _is_blocked(self) -> bool:
"""Единая проверка причин запрета записи.
Запись запрещена, если интеграция выключена переключателем
либо активирована блокировка force_stop.
"""
return (not self._enabled) or self._force_stopped
def _idle_or_disabled_state(self) -> str:
"""«Спокойное» состояние с учётом блокировок."""
return STATE_DISABLED if self._is_blocked() else STATE_IDLE
async def _force_disable(self, reason: str) -> None:
"""Жёсткая остановка всего и переход в DISABLED.
Единая точка для выключения переключателем и активации force_stop:
отменяет таймеры, останавливает запись, гасит зависшие задачи,
обнуляет стрим. Идемпотентна — повторный вызов безопасен.
"""
_LOGGER.info("Force disable: %s", reason)
await self._cancel_all_timers()
# Останавливаем запись в активной фазе (recording или delaying)
if self._state in (STATE_RECORDING, STATE_DELAYING):
await self._stop_recording()
# Гасим задачу, которая могла зависнуть на создании стрима/записи
if self._current_record_task and not self._current_record_task.done():
_LOGGER.info("Cancelling active record task (%s)", reason)
self._current_record_task.cancel()
try:
await asyncio.wait_for(
self._current_record_task, timeout=_TASK_CANCEL_TIMEOUT
)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
self._current_record_task = None
self._current_stream = None
await self._update_state(STATE_DISABLED)
# === Включение/выключение ===
async def set_enabled(self, enabled: bool) -> None: async def set_enabled(self, enabled: bool) -> None:
"""Переключить разрешение записи.""" """Set enabled state."""
self._enabled = enabled self._enabled = enabled
_LOGGER.info("Recording %s", "enabled" if enabled else "disabled") _LOGGER.info("Recording %s", "enabled" if enabled else "disabled")
if not enabled: if not enabled:
await self._force_disable("switch turned off") await self._cancel_all_timers()
elif self._force_stopped:
_LOGGER.info("Cannot enable: force stop is active") # Останавливаем запись если идёт
if self._state == STATE_RECORDING:
await self._stop_recording()
# Переходим в disabled
await self._update_state(STATE_DISABLED) await self._update_state(STATE_DISABLED)
else: else:
await self._update_state(STATE_IDLE) # Включаем только если не заблокированы force_stop
if self._force_stopped:
_LOGGER.info("Cannot enable: force stop is active")
await self._update_state(STATE_DISABLED)
else:
await self._update_state(STATE_IDLE)
self.async_set_updated_data({
"state": self._state,
"attributes": self.attributes
})
async def _cancel_all_timers(self) -> None: async def _cancel_all_timers(self) -> None:
"""Отменить все активные таймеры.""" """Отменить все активные таймеры."""
@@ -244,37 +189,35 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Cancelling motion detect timer") _LOGGER.debug("Cancelling motion detect timer")
self._motion_detect_timer() self._motion_detect_timer()
self._motion_detect_timer = None self._motion_detect_timer = None
if self._off_delay_timer: if self._off_delay_timer:
_LOGGER.debug("Cancelling off-delay timer") _LOGGER.debug("Cancelling off-delay timer")
self._off_delay_timer() self._off_delay_timer()
self._off_delay_timer = None self._off_delay_timer = None
# === Жизненный цикл ===
async def async_setup(self): async def async_setup(self):
"""Запуск координатора.""" """Set up the coordinator."""
_LOGGER.info("Setting up Motion Recorder coordinator for %s", self.camera_entity) _LOGGER.info("Setting up Motion Recorder coordinator for %s", self.camera_entity)
_LOGGER.info( _LOGGER.info(
"Settings: max_duration=%s, off_delay=%s, retention_days=%s, " "Settings: max_duration=%s, off_delay=%s, retention_days=%s, controlled_entities=%s, control_states=%s",
"controlled_entities=%s, control_states=%s",
self.max_duration, self.off_delay, self.retention_days, self.max_duration, self.off_delay, self.retention_days,
self.controlled_entities, self.control_states, self.controlled_entities, self.control_states
) )
await self._update_state(STATE_IDLE) await self._update_state(STATE_IDLE)
await self._start_listening() await self._start_listening()
# Ежедневная очистка в _CLEANUP_HOUR:00 # Запускаем ежедневную очистку в 3:00 ночи
self._cleanup_unsub = async_track_time_change( self._cleanup_unsub = async_track_time_change(
self.hass, self.hass,
self._cleanup_old_recordings, self._cleanup_old_recordings,
hour=_CLEANUP_HOUR, hour=3,
minute=0, minute=0,
second=0, second=0,
) )
_LOGGER.info("Daily cleanup scheduled at %02d:00", _CLEANUP_HOUR) _LOGGER.info("Daily cleanup scheduled at 03:00")
async def async_shutdown(self): async def async_shutdown(self):
"""Остановка координатора.""" """Shut down the coordinator."""
_LOGGER.info("Shutting down Motion Recorder coordinator") _LOGGER.info("Shutting down Motion Recorder coordinator")
await self._stop_listening() await self._stop_listening()
@@ -282,7 +225,7 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self._cleanup_unsub() self._cleanup_unsub()
self._cleanup_unsub = None self._cleanup_unsub = None
# Гасим устройства, которые включали сами # Выключаем все устройства, которые мы включили
for entity_id in list(self._we_turned_on): for entity_id in list(self._we_turned_on):
try: try:
domain = entity_id.split(".")[0] domain = entity_id.split(".")[0]
@@ -297,62 +240,55 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
if self._current_stream: if self._current_stream:
await self._stop_recording() await self._stop_recording()
# === Публикация состояния и управление устройствами ===
async def _update_state(self, state, **attributes): async def _update_state(self, state, **attributes):
"""Обновить состояние и атрибуты, опубликовать данные.""" """Update state and attributes."""
old_state = self._state old_state = self._state
self._state = state self._state = state
self._attributes.update(attributes) self._attributes.update(attributes)
_LOGGER.debug("State changed: %s → %s", old_state, state) _LOGGER.debug("State changed: %s → %s", old_state, state)
self.async_set_updated_data({ self.async_set_updated_data({
"state": state, "state": state,
"attributes": self.attributes, "attributes": self.attributes
}) })
# Управляем устройствами только при реальной смене состояния # Управляем устройствами при изменении состояния
if old_state != state: if old_state != state:
await self._update_controlled_entities() await self._update_controlled_entities()
async def _update_controlled_entities(self) -> None: async def _update_controlled_entities(self) -> None:
"""Синхронизировать управляемые устройства с состоянием. """Включить/выключить управляемые устройства в зависимости от состояния."""
Реагирует только на изменение логического флага should_be_on,
чтобы не опрашивать состояния сущностей при каждом переходе.
"""
if not self.controlled_entities: if not self.controlled_entities:
return return
should_be_on = self._state in self.control_states should_be_on = self._state in self.control_states
if should_be_on == self._control_should_be_on:
return
self._control_should_be_on = should_be_on
for entity_id in self.controlled_entities: for entity_id in self.controlled_entities:
try: try:
domain = entity_id.split(".")[0] domain = entity_id.split(".")[0]
current = self.hass.states.get(entity_id) current_state = self.hass.states.get(entity_id)
if current is None:
if current_state is None:
_LOGGER.warning("Controlled entity %s not found, skipping", entity_id) _LOGGER.warning("Controlled entity %s not found, skipping", entity_id)
continue continue
is_on = current.state == "on" is_on = current_state.state == "on"
if should_be_on and not is_on: if should_be_on and not is_on:
_LOGGER.info("Turning ON %s (state=%s)", entity_id, self._state) _LOGGER.info("Turning ON %s because state is '%s'", entity_id, self._state)
await self.hass.services.async_call( await self.hass.services.async_call(
domain, "turn_on", {"entity_id": entity_id}, blocking=False, domain, "turn_on", {"entity_id": entity_id}, blocking=False,
) )
self._we_turned_on.add(entity_id) self._we_turned_on.add(entity_id)
elif not should_be_on and is_on and entity_id in self._we_turned_on: elif not should_be_on and is_on and entity_id in self._we_turned_on:
_LOGGER.info("Turning OFF %s (state=%s)", entity_id, self._state) _LOGGER.info("Turning OFF %s because state is '%s'", entity_id, self._state)
await self.hass.services.async_call( await self.hass.services.async_call(
domain, "turn_off", {"entity_id": entity_id}, blocking=False, domain, "turn_off", {"entity_id": entity_id}, blocking=False,
) )
self._we_turned_on.discard(entity_id) self._we_turned_on.discard(entity_id)
except Exception as err:
_LOGGER.error("Error controlling %s: %s", entity_id, err, exc_info=True)
# === Очистка старых записей === except Exception as err:
_LOGGER.error("Error controlling entity %s: %s", entity_id, err, exc_info=True)
async def _cleanup_old_recordings(self, now=None): async def _cleanup_old_recordings(self, now=None):
"""Удалить записи старше retention_days.""" """Удалить записи старше retention_days."""
@@ -366,7 +302,8 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
return return
_LOGGER.info("Starting cleanup of recordings older than %d days", self.retention_days) _LOGGER.info("Starting cleanup of recordings older than %d days", self.retention_days)
cutoff_ts = (dt_util.now() - timedelta(days=self.retention_days)).timestamp()
cutoff_time = datetime.now() - timedelta(days=self.retention_days)
deleted_count = 0 deleted_count = 0
deleted_size = 0 deleted_size = 0
@@ -374,11 +311,11 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
nonlocal deleted_count, deleted_size nonlocal deleted_count, deleted_size
try: try:
for file_path in save_dir.glob("*.mp4"): for file_path in save_dir.glob("*.mp4"):
st = file_path.stat() # один stat на файл if file_path.stat().st_mtime < cutoff_time.timestamp():
if st.st_mtime < cutoff_ts: size = file_path.stat().st_size
file_path.unlink() file_path.unlink()
deleted_count += 1 deleted_count += 1
deleted_size += st.st_size deleted_size += size
_LOGGER.debug("Deleted old recording: %s", file_path.name) _LOGGER.debug("Deleted old recording: %s", file_path.name)
except Exception as err: except Exception as err:
_LOGGER.error("Error during cleanup: %s", err) _LOGGER.error("Error during cleanup: %s", err)
@@ -388,15 +325,13 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
if deleted_count > 0: if deleted_count > 0:
_LOGGER.info( _LOGGER.info(
"Cleanup completed: deleted %d files (%.1f MB)", "Cleanup completed: deleted %d files (%.1f MB)",
deleted_count, deleted_size / (1024 * 1024), deleted_count, deleted_size / (1024 * 1024)
) )
else: else:
_LOGGER.debug("No old recordings found to delete") _LOGGER.debug("No old recordings found to delete")
# === Подписки на сенсоры ===
async def _start_listening(self): async def _start_listening(self):
"""Подписаться на сенсоры движения и force_stop.""" """Start listening to motion sensors."""
_LOGGER.info("Starting to listen to motion sensors: %s", self.motion_sensors) _LOGGER.info("Starting to listen to motion sensors: %s", self.motion_sensors)
for sensor in self.motion_sensors: for sensor in self.motion_sensors:
unsub = async_track_state_change_event( unsub = async_track_state_change_event(
@@ -405,16 +340,13 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self._unsub_motion_listeners.append(unsub) self._unsub_motion_listeners.append(unsub)
if self.force_stop_sensor: if self.force_stop_sensor:
_LOGGER.info( _LOGGER.info("Force stop sensor: %s → %s", self.force_stop_sensor, self.force_stop_state)
"Force stop sensor: %s → %s",
self.force_stop_sensor, self.force_stop_state,
)
self._unsub_force_stop_listener = async_track_state_change_event( self._unsub_force_stop_listener = async_track_state_change_event(
self.hass, [self.force_stop_sensor], self._force_stop_state_changed self.hass, [self.force_stop_sensor], self._force_stop_state_changed
) )
async def _stop_listening(self): async def _stop_listening(self):
"""Отписаться от сенсоров.""" """Stop listening to sensors."""
for unsub in self._unsub_motion_listeners: for unsub in self._unsub_motion_listeners:
unsub() unsub()
self._unsub_motion_listeners = [] self._unsub_motion_listeners = []
@@ -425,40 +357,41 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
@callback @callback
def _motion_state_changed(self, event): def _motion_state_changed(self, event):
"""Обработка смены состояния сенсора движения — только фронты.""" """Handle motion sensor state change — ONLY on edges (fronts)."""
old_state = event.data.get("old_state") old_state = event.data.get("old_state")
new_state = event.data.get("new_state") new_state = event.data.get("new_state")
if not new_state:
return
# Не плодим задачи, пока запись запрещена или идёт финализация if not new_state:
if self._is_blocked():
return
if self._state == STATE_FINALIZING:
_LOGGER.debug("Ignoring motion event during %s", self._state)
return return
sensor_entity = event.data.get("entity_id") sensor_entity = event.data.get("entity_id")
old_s = old_state.state if old_state else None old_s = old_state.state if old_state else None
new_s = new_state.state new_s = new_state.state
if self._state == STATE_FINALIZING:
_LOGGER.debug("Ignoring motion event during %s", self._state)
return
_LOGGER.debug("Motion event: %s %s → %s", sensor_entity, old_s, new_s) _LOGGER.debug("Motion event: %s %s → %s", sensor_entity, old_s, new_s)
if new_s == "on" and old_s != "on": if new_s == "on" and old_s != "on":
_LOGGER.debug("Motion FRONT UP: %s (%s → %s)", sensor_entity, old_s, new_s) _LOGGER.debug("Motion FRONT UP: %s (%s → %s)", sensor_entity, old_s, new_s)
self.hass.async_create_task(self._handle_motion_detected(sensor_entity)) self.hass.async_create_task(self._handle_motion_detected(sensor_entity))
elif old_s == "on" and new_s != "on": elif old_s == "on" and new_s != "on":
_LOGGER.debug("Motion FRONT DOWN: %s (%s → %s)", sensor_entity, old_s, new_s) _LOGGER.debug("Motion FRONT DOWN: %s (%s → %s)", sensor_entity, old_s, new_s)
self.hass.async_create_task(self._handle_motion_stopped()) self.hass.async_create_task(self._handle_motion_stopped())
@callback @callback
def _force_stop_state_changed(self, event): def _force_stop_state_changed(self, event):
"""Обработка смены состояния сенсора force_stop.""" """Handle force stop sensor state change."""
new_state = event.data.get("new_state") new_state = event.data.get("new_state")
if not new_state: if not new_state:
return return
new_s = new_state.state new_s = new_state.state
sensor_entity = event.data.get("entity_id") sensor_entity = event.data.get("entity_id")
_LOGGER.debug("Force stop sensor %s changed to: %s", sensor_entity, new_s) _LOGGER.debug("Force stop sensor %s changed to: %s", sensor_entity, new_s)
if new_s == self.force_stop_state: if new_s == self.force_stop_state:
@@ -469,20 +402,33 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self.hass.async_create_task(self._deactivate_force_stop()) self.hass.async_create_task(self._deactivate_force_stop())
async def _activate_force_stop(self) -> None: async def _activate_force_stop(self) -> None:
"""Активировать блокировку force_stop.""" """Активировать force stop блокировку."""
self._force_stopped = True self._force_stopped = True
await self._force_disable("force stop sensor") _LOGGER.info("Force stop blocking activated")
# Отменяем все таймеры
await self._cancel_all_timers()
# Останавливаем запись если идёт
if self._state == STATE_RECORDING:
await self._stop_recording()
# Переходим в disabled
await self._update_state(STATE_DISABLED)
async def _deactivate_force_stop(self) -> None: async def _deactivate_force_stop(self) -> None:
"""Снять блокировку force_stop.""" """Деактивировать force stop блокировку."""
self._force_stopped = False self._force_stopped = False
_LOGGER.info("Force stop blocking deactivated") _LOGGER.info("Force stop blocking deactivated")
await self._update_state(self._idle_or_disabled_state())
# === Обработчики движения === # Возвращаемся в idle только если запись включена
if self._enabled:
await self._update_state(STATE_IDLE)
else:
await self._update_state(STATE_DISABLED)
def _is_any_motion_active(self) -> bool: def _is_any_motion_active(self) -> bool:
"""Активен ли сейчас хоть один сенсор движения.""" """Check if any motion sensor is currently in 'on' state."""
for sensor in self.motion_sensors: for sensor in self.motion_sensors:
state = self.hass.states.get(sensor) state = self.hass.states.get(sensor)
if state and state.state == "on": if state and state.state == "on":
@@ -490,20 +436,20 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
return False return False
async def _handle_motion_detected(self, triggered_by): async def _handle_motion_detected(self, triggered_by):
"""Обработка обнаружения движения с учётом фильтра.""" """Handle motion detection with filter."""
if self._is_blocked(): if not self._enabled:
_LOGGER.debug("Recording blocked, ignoring motion") _LOGGER.debug("Recording disabled, ignoring motion")
return
if self._force_stopped:
_LOGGER.debug("Force stop active, ignoring motion")
return return
_LOGGER.debug("Motion detected by %s, current state: %s", triggered_by, self._state) _LOGGER.debug("Motion detected by %s, current state: %s", triggered_by, self._state)
if self._state == STATE_RECORDING: if self._state == STATE_RECORDING:
_LOGGER.debug("Already recording, updating last motion time") _LOGGER.debug("Already recording, updating last motion time")
# Публикуем атрибут сразу, чтобы UI не «отставал» self._attributes["last_motion_time"] = dt_util.utcnow().isoformat()
await self._update_state(
STATE_RECORDING,
last_motion_time=dt_util.utcnow().isoformat(),
)
return return
if self._state == STATE_DETECTING: if self._state == STATE_DETECTING:
@@ -511,7 +457,7 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
return return
if self._state == STATE_DELAYING: if self._state == STATE_DELAYING:
_LOGGER.info("Motion returned during off-delay, continuing recording") _LOGGER.info("Motion returned during off-delay, cancelling delay and continuing recording")
if self._off_delay_timer: if self._off_delay_timer:
_LOGGER.debug("Cancelling off-delay timer") _LOGGER.debug("Cancelling off-delay timer")
self._off_delay_timer() self._off_delay_timer()
@@ -539,21 +485,22 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
await self._start_recording_after_filter(None) await self._start_recording_after_filter(None)
async def _start_recording_after_filter(self, _=None): async def _start_recording_after_filter(self, _=None):
"""Старт записи после отработки фильтра.""" """Start recording after motion filter."""
self._motion_detect_timer = None if not self._enabled or self._force_stopped:
if self._is_blocked(): _LOGGER.debug("Cannot start recording: enabled=%s, force_stopped=%s",
_LOGGER.debug("Cannot start recording after filter: blocked") self._enabled, self._force_stopped)
await self._update_state(self._idle_or_disabled_state()) await self._update_state(STATE_IDLE if self._enabled else STATE_DISABLED)
return return
_LOGGER.debug("Starting recording after filter") _LOGGER.debug("Starting recording after filter")
self._motion_detect_timer = None
triggered_by = self._attributes.get("triggered_by") triggered_by = self._attributes.get("triggered_by")
await self._start_recording(triggered_by) await self._start_recording(triggered_by)
async def _handle_motion_stopped(self): async def _handle_motion_stopped(self):
"""Обработка прекращения движения с off-delay.""" """Handle motion stopped with off-delay."""
if self._is_blocked(): if self._force_stopped:
_LOGGER.debug("Recording blocked, ignoring motion stopped") _LOGGER.debug("Force stop active, ignoring motion stopped")
return return
_LOGGER.debug("Motion stopped, current state: %s", self._state) _LOGGER.debug("Motion stopped, current state: %s", self._state)
@@ -591,16 +538,21 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Cancelling motion filter timer") _LOGGER.debug("Cancelling motion filter timer")
self._motion_detect_timer() self._motion_detect_timer()
self._motion_detect_timer = None self._motion_detect_timer = None
await self._update_state(self._idle_or_disabled_state())
if not self._enabled:
await self._update_state(STATE_DISABLED)
else:
await self._update_state(STATE_IDLE)
async def _stop_recording_after_delay(self, _=None): async def _stop_recording_after_delay(self, _=None):
"""Остановка записи по истечении off-delay.""" """Stop recording after off-delay."""
self._off_delay_timer = None if self._force_stopped or not self._enabled:
if self._is_blocked(): _LOGGER.debug("Cannot stop recording: enabled=%s, force_stopped=%s",
_LOGGER.debug("Recording blocked, skip stop after delay") self._enabled, self._force_stopped)
return return
_LOGGER.debug("Off-delay timer expired") _LOGGER.debug("Off-delay timer expired")
self._off_delay_timer = None
if self._is_any_motion_active(): if self._is_any_motion_active():
_LOGGER.info("Motion is still active — continuing recording") _LOGGER.info("Motion is still active — continuing recording")
@@ -610,16 +562,14 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.info("No motion detected, stopping recording") _LOGGER.info("No motion detected, stopping recording")
await self._stop_recording() await self._stop_recording()
# === Запись ===
async def _start_recording(self, triggered_by): async def _start_recording(self, triggered_by):
"""Запустить запись камеры.""" """Start recording the camera."""
if self._is_blocked(): if not self._enabled or self._force_stopped:
_LOGGER.warning("Cannot start recording: blocked") _LOGGER.warning("Cannot start recording: enabled=%s, force_stopped=%s",
await self._update_state(self._idle_or_disabled_state()) self._enabled, self._force_stopped)
await self._update_state(STATE_IDLE if self._enabled else STATE_DISABLED)
return return
filename = None
try: try:
if self._current_stream: if self._current_stream:
recorder_output = self._current_stream.outputs().get(_RECORDER_PROVIDER) recorder_output = self._current_stream.outputs().get(_RECORDER_PROVIDER)
@@ -628,10 +578,11 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
return return
self._recording_start_time = dt_util.utcnow() self._recording_start_time = dt_util.utcnow()
await self._update_state( await self._update_state(
STATE_RECORDING, STATE_RECORDING,
triggered_by=triggered_by, triggered_by=triggered_by,
recording_start_time=self._recording_start_time.isoformat(), recording_start_time=self._recording_start_time.isoformat()
) )
component = self.hass.data.get("camera") component = self.hass.data.get("camera")
@@ -642,19 +593,7 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
if not camera: if not camera:
raise Exception(f"Camera {self.camera_entity} not found") raise Exception(f"Camera {self.camera_entity} not found")
# Таймаут на создание стрима: камера может быть недоступна stream = await camera.async_create_stream()
_LOGGER.debug("Creating stream for %s (timeout=%.0fs)",
self.camera_entity, _STREAM_CREATE_TIMEOUT)
try:
stream = await asyncio.wait_for(
camera.async_create_stream(), timeout=_STREAM_CREATE_TIMEOUT
)
except asyncio.TimeoutError as err:
raise Exception(
f"Timeout creating stream for {self.camera_entity} "
f"— camera may be unavailable"
) from err
if not stream: if not stream:
raise Exception(f"Could not create stream for {self.camera_entity}") raise Exception(f"Could not create stream for {self.camera_entity}")
@@ -663,13 +602,13 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
save_dir = self._get_save_dir() save_dir = self._get_save_dir()
await self.hass.async_add_executor_job(_mkdir_parents, save_dir) await self.hass.async_add_executor_job(_mkdir_parents, save_dir)
timestamp = dt_util.now().strftime(self.filename_template) timestamp = datetime.now().strftime(self.filename_template)
filename = save_dir / f"{timestamp}.mp4" filename = save_dir / f"{timestamp}.mp4"
self._current_filename = str(filename) self._current_filename = str(filename)
_LOGGER.info( _LOGGER.info(
"Recording started: %s (duration=%ds, lookback=%ds)", "Recording started: %s (duration=%ds, lookback=%ds)",
filename, self.max_duration, self.prebuffer, filename, self.max_duration, self.prebuffer
) )
self._current_record_task = asyncio.create_task( self._current_record_task = asyncio.create_task(
@@ -680,35 +619,21 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
) )
) )
# Ждём завершения с корректной обработкой отмены await self._current_record_task
try:
await self._current_record_task
except asyncio.CancelledError:
_LOGGER.info("Recording task was cancelled (stop requested)")
await self._finalize_recording(str(filename)) await self._finalize_recording(str(filename))
except asyncio.CancelledError:
_LOGGER.info("Recording start was cancelled")
if filename:
await self._finalize_recording(str(filename))
except Exception as err: except Exception as err:
_LOGGER.error("Error starting recording: %s", err, exc_info=True) _LOGGER.error("Error starting recording: %s", err, exc_info=True)
# При блокировке не мигаем error — сразу в спокойное состояние
if self._is_blocked():
await self._update_state(self._idle_or_disabled_state())
return
await self._update_state(STATE_ERROR, error_message=str(err)) await self._update_state(STATE_ERROR, error_message=str(err))
await asyncio.sleep(_ERROR_RECOVERY_DELAY) await asyncio.sleep(5)
await self._update_state(self._idle_or_disabled_state()) if not self._enabled:
await self._update_state(STATE_DISABLED)
else:
await self._update_state(STATE_IDLE)
async def _stop_recording(self): async def _stop_recording(self):
"""Остановить текущую запись.""" """Stop current recording."""
_LOGGER.debug( _LOGGER.debug("Stop recording called, current_stream: %s", self._current_stream is not None)
"Stop recording called, stream=%s, task=%s",
self._current_stream is not None,
self._current_record_task is not None,
)
if not self._current_stream: if not self._current_stream:
_LOGGER.warning("No current stream, nothing to stop") _LOGGER.warning("No current stream, nothing to stop")
@@ -721,23 +646,17 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
await self._current_stream.remove_provider(recorder_output) await self._current_stream.remove_provider(recorder_output)
if self._current_record_task and not self._current_record_task.done(): if self._current_record_task and not self._current_record_task.done():
_LOGGER.debug("Waiting for record task (timeout=%.0fs)", _RECORD_STOP_TIMEOUT) _LOGGER.debug("Waiting for record task to complete after stop signal")
try: try:
await asyncio.wait_for( await asyncio.wait_for(self._current_record_task, timeout=10.0)
self._current_record_task, timeout=_RECORD_STOP_TIMEOUT
)
_LOGGER.debug("Record task completed after stop") _LOGGER.debug("Record task completed after stop")
except asyncio.TimeoutError: except asyncio.TimeoutError:
_LOGGER.warning("Record task did not complete, cancelling") _LOGGER.warning("Record task did not complete within timeout, cancelling")
self._current_record_task.cancel() self._current_record_task.cancel()
try: try:
await asyncio.wait_for( await self._current_record_task
self._current_record_task, timeout=_TASK_CANCEL_TIMEOUT except asyncio.CancelledError:
) pass
except (asyncio.CancelledError, asyncio.TimeoutError):
_LOGGER.warning("Record task cancel timed out")
except asyncio.CancelledError:
_LOGGER.debug("Record task was cancelled")
_LOGGER.debug("Recording stopped successfully") _LOGGER.debug("Recording stopped successfully")
else: else:
@@ -745,32 +664,36 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
except Exception as err: except Exception as err:
_LOGGER.error("Error stopping recording: %s", err, exc_info=True) _LOGGER.error("Error stopping recording: %s", err, exc_info=True)
# Принудительная отмена зависшей задачи при любой ошибке
if self._current_record_task and not self._current_record_task.done():
self._current_record_task.cancel()
await self._update_state(STATE_ERROR, error_message=str(err)) await self._update_state(STATE_ERROR, error_message=str(err))
await asyncio.sleep(_ERROR_RECOVERY_DELAY) await asyncio.sleep(5)
await self._update_state(self._idle_or_disabled_state()) if not self._enabled or self._force_stopped:
await self._update_state(STATE_DISABLED)
else:
await self._update_state(STATE_IDLE)
async def _force_stop_recording(self): async def _force_stop_recording(self):
"""Принудительная остановка записи (legacy-точка входа).""" """Force stop recording (вызывается при активации force_stop)."""
_LOGGER.info("Force stop recording called") _LOGGER.info("Force stop recording called")
await self._stop_recording() await self._stop_recording()
async def _finalize_recording(self, filename): async def _finalize_recording(self, filename):
"""Финализация записи и проверка файла.""" """Finalize recording and check file."""
_LOGGER.debug("Finalizing recording: %s", filename) _LOGGER.debug("Finalizing recording: %s", filename)
await self._update_state(STATE_FINALIZING, last_file_path=filename) await self._update_state(STATE_FINALIZING, last_file_path=filename)
filepath = Path(filename) filepath = Path(filename)
for _ in range(_FINALIZE_POLL_TRIES):
await asyncio.sleep(_FINALIZE_POLL_STEP) for _ in range(10):
await asyncio.sleep(0.2)
if filepath.is_file(): if filepath.is_file():
if filepath.stat().st_size > _MIN_RECORDING_SIZE: size = filepath.stat().st_size
if size > _MIN_RECORDING_SIZE:
break break
if filepath.is_file() and filepath.stat().st_size > _MIN_RECORDING_SIZE: if filepath.is_file() and filepath.stat().st_size > _MIN_RECORDING_SIZE:
size = filepath.stat().st_size size = filepath.stat().st_size
if self._recording_start_time: if self._recording_start_time:
duration = (dt_util.utcnow() - self._recording_start_time).total_seconds() duration = (dt_util.utcnow() - self._recording_start_time).total_seconds()
else: else:
@@ -781,22 +704,20 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self._total_size += size self._total_size += size
_LOGGER.info( _LOGGER.info(
"Recording completed: %s (%.1f sec, %d bytes). " "Recording completed: %s (%.1f sec, %d bytes). Total: %d recordings, %d sec, %d bytes",
"Total: %d recordings, %d sec, %d bytes",
filepath, duration, size, filepath, duration, size,
self._recordings_count, self._total_duration, self._total_size, self._recordings_count, self._total_duration, self._total_size
) )
else: else:
_LOGGER.warning("Recording file not found or too small: %s", filename) _LOGGER.warning("Recording file not found or too small: %s", filename)
# Освобождаем ресурсы
self._current_stream = None self._current_stream = None
self._current_record_task = None self._current_record_task = None
self._recording_start_time = None self._recording_start_time = None
self._current_filename = None self._current_filename = None
if self._is_blocked(): if not self._enabled or self._force_stopped:
_LOGGER.debug("Blocked during finalization, transitioning to disabled") _LOGGER.debug("Recording disabled or force stopped during finalization, transitioning to disabled")
await self._update_state(STATE_DISABLED) await self._update_state(STATE_DISABLED)
else: else:
_LOGGER.debug("Transitioning to idle") _LOGGER.debug("Transitioning to idle")