@@ -1,11 +1,15 @@
""" Coordinator for Motion Recorder. """
""" Coordinator for Motion Recorder. """
import asyncio
import asyncio
import logging
import logging
from datetime import datetime , timedelta
from datetime import 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 async_track_state_change_event , async_call_later , async_track_time_change
from homeassistant . helpers . event import (
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 (
@@ -39,26 +43,36 @@ 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 ) :
""" Get config value from options first, then from data, then default."""
""" Значение настройки: сначала options, потом data, потом 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 args для executor. """
""" Path.mkdir с keyword-аргументами для executor. """
p . mkdir ( parents = True , exist_ok = True )
p . mkdir ( parents = True , exist_ok = True )
class MotionRecorderCoordinator ( DataUpdateCoordinator ) :
class MotionRecorderCoordinator ( DataUpdateCoordinator ) :
""" Motion Recorder coordinator . """
""" Координатор Motion Recorder."""
def __init__ ( self , hass : HomeAssistant , entry ) - > None :
def __init__ ( self , hass : HomeAssistant , entry ) - > None :
""" Initialize coordinator ."""
""" Инициализация координатора ."""
super ( ) . __init__ (
super ( ) . __init__ (
hass ,
hass ,
_LOGGER ,
_LOGGER ,
@@ -68,9 +82,11 @@ 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 ( entry , CONF_FILENAME_TEMPLATE , " %d - % m- % Y_ % H- % M- % S " )
self . filename_template = _get_config_value (
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 , [ ] )
@@ -78,44 +94,52 @@ 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 ( entry , CONF_RETENTION_DAYS , DEFAULT_RETENTION_DAYS )
self . retention_days = _get_config_value (
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 ( entry , CONF_CONTROL_STATES , DEFAULT_CONTROL_STATES )
self . control_states = _get_config_value (
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 = { }
self . _attributes : dict = { }
# Enabled state
# Флаги разрешения/блокировки записи
self . _enabled = True
self . _enabled = True
self . _force_stopped = False
self . _force_stopped = False
# Listeners
# Подписки
self . _unsub_motion_listeners = [ ]
self . _unsub_motion_listeners : list = [ ]
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 :
@@ -127,23 +151,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
@@ -153,71 +177,104 @@ 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 . _cancel_all_timers ( )
await self . _force_disable ( " switch turned off " )
elif self . _force_stopped :
# Останавливаем запись если идёт
if self . _state == STATE_RECORDING :
await self . _stop_recording ( )
# Переходим в disabled
await self . _update_state ( STATE_DISABLED )
else :
# Включаем только если не заблокированы force_stop
if self . _force_stopped :
_LOGGER . info ( " Cannot enable: force stop is active " )
_LOGGER . info ( " Cannot enable: force stop is active " )
await self . _update_state ( STATE_DISABLED )
await self . _update_state ( STATE_DISABLED )
else :
else :
await self . _update_state ( STATE_IDLE )
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 :
""" Отменить все активные таймеры. """
""" Отменить все активные таймеры. """
if self . _motion_detect_timer :
if self . _motion_detect_timer :
_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 , controlled_entities= %s , control_states= %s " ,
" Settings: max_duration= %s , off_delay= %s , retention_days= %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 ( )
# Запускаем е жедневную очистку в 3:00 ночи
# Е жедневная очистка в _CLEANUP_HOUR: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 = 3 ,
hour = _CLEANUP_HOUR ,
minute = 0 ,
minute = 0 ,
second = 0 ,
second = 0 ,
)
)
_LOGGER . info ( " Daily cleanup scheduled at 03 :00 " )
_LOGGER . info ( " Daily cleanup scheduled at %02d :00 " , _CLEANUP_HOUR )
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 ( )
@@ -225,7 +282,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 ]
@@ -240,55 +297,62 @@ 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_state = self . hass . states . get ( entity_id )
current = 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 . state == " on "
is_on = current . state == " on "
if should_be_on and not is_on :
if should_be_on and not is_on :
_LOGGER . info ( " Turning ON %s because state is ' %s ' " , entity_id , self . _state )
_LOGGER . info ( " Turning ON %s (state= %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 because state is ' %s ' " , entity_id , self . _state )
_LOGGER . info ( " Turning OFF %s (state= %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 :
except Exception as err :
_LOGGER . error ( " Error controlling entity %s : %s " , entity_id , err , exc_info = True )
_LOGGER . error ( " Error controlling %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. """
@@ -302,8 +366,7 @@ 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
@@ -311,11 +374,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 " ) :
if file_path . stat ( ) . st_mtime < cutoff_time . timestamp ( ) :
st = file_path . stat ( ) # один stat на файл
size = file_path . stat ( ) . st_size
if st . st_mtime < cutoff_ts :
file_path . unlink ( )
file_path . unlink ( )
deleted_count + = 1
deleted_count + = 1
deleted_size + = size
deleted_size + = st . st_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 )
@@ -325,13 +388,15 @@ 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 ) :
""" Start listening to motion sensors ."""
""" Подписаться на сенсоры движения и force_stop ."""
_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 (
@@ -340,13 +405,16 @@ 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 ( " Force stop sensor: %s → %s " , self . force_stop_sensor , self . force_stop_state )
_LOGGER . info (
" 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 = [ ]
@@ -357,41 +425,40 @@ 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 :
if not new_state :
return
return
# Не плодим задачи, пока запись запрещена или идёт финализация
if self . _is_blocked ( ) :
return
if self . _state == STATE_FINALIZING :
_LOGGER . debug ( " Ignoring motion event during %s " , self . _state )
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 ) :
""" Handle force stop sensor state change ."""
""" Обработка смены состояния сенсора force_ stop."""
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 :
@@ -402,33 +469,20 @@ 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
_LOGGER . info ( " F orce stop blocking activated " )
await self . _force_disable ( " f orce stop sensor " )
# Отменяем все таймеры
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 " :
@@ -436,20 +490,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 not self . _enabled :
if self . _is_blocked ( ) :
_LOGGER . debug ( " Recording disabl ed, ignoring motion " )
_LOGGER . debug ( " Recording block ed, 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 " )
self . _attributes [ " last_motion_time " ] = dt_util . utcnow ( ) . isoformat ( )
# Публикуем атрибут сразу, чтобы UI не «отставал»
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 :
@@ -457,7 +511,7 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
return
return
if self . _state == STATE_DELAYING :
if self . _state == STATE_DELAYING :
_LOGGER . info ( " Motion returned during off-delay, cancelling delay and continuing recording " )
_LOGGER . info ( " Motion returned during off-delay, 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 ( )
@@ -485,22 +539,21 @@ 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 ."""
""" Старт записи после отработки фильтра ."""
if not self . _enabled or self . _force_stopped :
self . _motion_detect_timer = None
_LOGGER . debug ( " Cannot start recording: enabled= %s , force_stopped= %s " ,
if self . _is_blocked ( ) :
self . _enabled , self . _force_stopped )
_LOGGER . debug ( " Cannot start recording after filter: blocked " )
await self . _update_state ( STATE_IDLE if self . _enabled else STATE_DISABLED )
await self . _update_state ( self . _idle_or_disabled_state ( ) )
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 ) :
""" Handle motion stopped with off-delay."""
""" Обработка прекращения движения с off-delay."""
if self . _force_stopped :
if self . _is_blocked ( ) :
_LOGGER . debug ( " Force stop active , ignoring motion stopped" )
_LOGGER . debug ( " Recording blocked , ignoring motion stopped" )
return
return
_LOGGER . debug ( " Motion stopped, current state: %s " , self . _state )
_LOGGER . debug ( " Motion stopped, current state: %s " , self . _state )
@@ -538,21 +591,16 @@ 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 ) :
""" Stop recording after off-delay."""
""" Остановка записи по истечении off-delay."""
if self . _force_stopped or not self . _enabled :
self . _off_delay_timer = None
_LOGGER . debug ( " Cannot stop recording: enabled= %s , force_stopped= %s " ,
if self . _is_blocked ( ) :
self . _enabled , self . _force_stopped )
_LOGGER . debug ( " Recording blocked, skip stop after delay " )
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 " )
@@ -562,14 +610,16 @@ 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 not self . _enabled or self . _force_stopped :
if self . _is_blocked ( ) :
_LOGGER . warning ( " Cannot start recording: enabled= %s , force_stopped= %s " ,
_LOGGER . warning ( " Cannot start recording: 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
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 )
@@ -578,11 +628,10 @@ 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 " )
@@ -593,7 +642,19 @@ 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= %.0f s) " ,
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 } " )
@@ -602,13 +663,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 = datetime . now ( ) . strftime ( self . filename_template )
timestamp = dt_util . 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= %d s, lookback= %d s) " ,
" Recording started: %s (duration= %d s, lookback= %d s) " ,
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 (
@@ -619,21 +680,35 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
)
)
)
)
# Ждём завершения с корректной обработкой отмены
try :
await self . _current_record_task
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 ( 5 )
await asyncio . sleep ( _ERROR_RECOVERY_DELAY )
if not self . _enabled :
await self . _update_state ( self . _idle_or_disabled_state ( ) )
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 ( " Stop recording called, current_stream: %s " , self . _current_stream is not None )
_LOGGER . debug (
" 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 " )
@@ -646,17 +721,23 @@ 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 to complete after stop signal " )
_LOGGER . debug ( " Waiting for record task (timeout= %.0f s) " , _RECORD_STOP_TIMEOUT )
try :
try :
await asyncio . wait_for ( self . _current_record_task , timeout = 10.0 )
await asyncio . wait_for (
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 within timeout , cancelling " )
_LOGGER . warning ( " Record task did not complete, cancelling " )
self . _current_record_task . cancel ( )
self . _current_record_task . cancel ( )
try :
try :
await self . _current_record_task
await asyncio . wait_for (
self . _current_record_task , timeout = _TASK_CANCEL_TIMEOUT
)
except ( asyncio . CancelledError , asyncio . TimeoutError ) :
_LOGGER . warning ( " Record task cancel timed out " )
except asyncio . CancelledError :
except asyncio . CancelledError :
pass
_LOGGER . debug ( " Record task was cancelled " )
_LOGGER . debug ( " Recording stopped successfully " )
_LOGGER . debug ( " Recording stopped successfully " )
else :
else :
@@ -664,36 +745,32 @@ 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 ( 5 )
await asyncio . sleep ( _ERROR_RECOVERY_DELAY )
if not self . _enabled or self . _force_stopped :
await self . _update_state ( self . _idle_or_disabled_state ( ) )
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 ) :
""" Force stop recording (вызывается при активации force_stop )."""
""" Принудительная остановка записи (legacy-точка входа )."""
_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 ) :
for _ in range ( 10 ) :
await asyncio . sleep ( _FINALIZE_POLL_STEP )
await asyncio . sleep ( 0.2 )
if filepath . is_file ( ) :
if filepath . is_file ( ) :
size = filepath . stat ( ) . st_size
if filepath . stat ( ) . st_size > _MIN_RECORDING_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 :
@@ -704,20 +781,22 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self . _total_size + = size
self . _total_size + = size
_LOGGER . info (
_LOGGER . info (
" Recording completed: %s ( %.1f sec, %d bytes). Total: %d recordings, %d sec, %d bytes " ,
" Recording completed: %s ( %.1f 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 not self . _enabled or self . _force_stopped :
if self . _is_blocked ( ) :
_LOGGER . debug ( " Recording disabled or force stopp ed during finalization, transitioning to disabled" )
_LOGGER . debug ( " Block ed 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 " )