Обновить custom_components/motion_recorder/coordinator.py

This commit is contained in:
2026-07-05 13:32:29 +03:00
parent 02cb16325c
commit 015e3221df
+118 -115
View File
@@ -21,13 +21,15 @@ from .const import (
CONF_FORCE_STOP_SENSOR, CONF_FORCE_STOP_SENSOR,
CONF_FORCE_STOP_STATE, CONF_FORCE_STOP_STATE,
CONF_RETENTION_DAYS, CONF_RETENTION_DAYS,
CONF_CONTROLLED_ENTITIES,
CONF_CONTROL_STATES,
DEFAULT_RETENTION_DAYS, DEFAULT_RETENTION_DAYS,
DEFAULT_CONTROL_STATES,
STATE_IDLE, STATE_IDLE,
STATE_DETECTING, STATE_DETECTING,
STATE_RECORDING, STATE_RECORDING,
STATE_DELAYING, STATE_DELAYING,
STATE_FINALIZING, STATE_FINALIZING,
STATE_COMPLETED,
STATE_ERROR, STATE_ERROR,
STATE_DISABLED, STATE_DISABLED,
) )
@@ -39,7 +41,6 @@ except ImportError:
_MIN_RECORDING_SIZE = 1024 _MIN_RECORDING_SIZE = 1024
# Глобальный логгер для всего модуля
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -79,6 +80,11 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
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.control_states = _get_config_value(entry, CONF_CONTROL_STATES, DEFAULT_CONTROL_STATES)
self._we_turned_on: set[str] = set()
# State management # State management
self._state = STATE_IDLE self._state = STATE_IDLE
self._attributes = {} self._attributes = {}
@@ -153,21 +159,25 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.info("Recording %s", "enabled" if enabled else "disabled") _LOGGER.info("Recording %s", "enabled" if enabled else "disabled")
if not enabled: if not enabled:
# Останавливаем текущую запись, если она идёт
if self._state == STATE_RECORDING: if self._state == STATE_RECORDING:
await self._stop_recording() await self._stop_recording()
# Переходим в состояние "disabled"
await self._update_state(STATE_DISABLED) await self._update_state(STATE_DISABLED)
else: else:
# Возвращаемся в idle (только если сейчас disabled) await self._update_state(STATE_IDLE)
if self._state == STATE_DISABLED:
await self._update_state(STATE_IDLE) self.async_set_updated_data({
"state": self._state,
"attributes": self.attributes
})
async def async_setup(self): async def async_setup(self):
"""Set up the coordinator.""" """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("Settings: max_duration=%s, off_delay=%s, retention_days=%s", _LOGGER.info(
self.max_duration, self.off_delay, self.retention_days) "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.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()
@@ -190,6 +200,18 @@ 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):
try:
domain = entity_id.split(".")[0]
_LOGGER.info("Shutdown: turning off %s", entity_id)
await self.hass.services.async_call(
domain, "turn_off", {"entity_id": entity_id}, blocking=False,
)
except Exception as err:
_LOGGER.error("Error turning off %s on shutdown: %s", entity_id, err)
self._we_turned_on.clear()
if self._current_stream: if self._current_stream:
await self._stop_recording() await self._stop_recording()
@@ -204,6 +226,85 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
"attributes": self.attributes "attributes": self.attributes
}) })
# Управляем устройствами при изменении состояния
if old_state != state:
await self._update_controlled_entities()
async def _update_controlled_entities(self) -> None:
"""Включить/выключить управляемые устройства в зависимости от состояния."""
if not self.controlled_entities:
return
should_be_on = self._state in self.control_states
for entity_id in self.controlled_entities:
try:
domain = entity_id.split(".")[0]
current_state = self.hass.states.get(entity_id)
if current_state is None:
_LOGGER.warning("Controlled entity %s not found, skipping", entity_id)
continue
is_on = current_state.state == "on"
if should_be_on and not is_on:
_LOGGER.info("Turning ON %s because state is '%s'", entity_id, self._state)
await self.hass.services.async_call(
domain, "turn_on", {"entity_id": entity_id}, blocking=False,
)
self._we_turned_on.add(entity_id)
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)
await self.hass.services.async_call(
domain, "turn_off", {"entity_id": entity_id}, blocking=False,
)
self._we_turned_on.discard(entity_id)
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):
"""Удалить записи старше retention_days."""
if self.retention_days == 0:
_LOGGER.debug("Retention days is 0, skipping cleanup")
return
save_dir = self._get_save_dir()
if not save_dir.exists():
_LOGGER.debug("Save directory does not exist: %s", save_dir)
return
_LOGGER.info("Starting cleanup of recordings older than %d days", self.retention_days)
cutoff_time = datetime.now() - timedelta(days=self.retention_days)
deleted_count = 0
deleted_size = 0
def _cleanup():
nonlocal deleted_count, deleted_size
try:
for file_path in save_dir.glob("*.mp4"):
if file_path.stat().st_mtime < cutoff_time.timestamp():
size = file_path.stat().st_size
file_path.unlink()
deleted_count += 1
deleted_size += size
_LOGGER.debug("Deleted old recording: %s", file_path.name)
except Exception as err:
_LOGGER.error("Error during cleanup: %s", err)
await self.hass.async_add_executor_job(_cleanup)
if deleted_count > 0:
_LOGGER.info(
"Cleanup completed: deleted %d files (%.1f MB)",
deleted_count, deleted_size / (1024 * 1024)
)
else:
_LOGGER.debug("No old recordings found to delete")
async def _start_listening(self): async def _start_listening(self):
"""Start listening to motion sensors.""" """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)
@@ -242,19 +343,16 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
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:
if self._state in (STATE_FINALIZING, STATE_COMPLETED):
_LOGGER.debug("Ignoring motion event during %s", self._state) _LOGGER.debug("Ignoring motion event during %s", self._state)
return 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())
@@ -315,9 +413,7 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Starting motion filter timer for %d seconds", self.motion_filter) _LOGGER.debug("Starting motion filter timer for %d seconds", self.motion_filter)
try: try:
self._motion_detect_timer = async_call_later( self._motion_detect_timer = async_call_later(
self.hass, self.hass, self.motion_filter, self._start_recording_after_filter
self.motion_filter,
self._start_recording_after_filter
) )
except Exception as err: except Exception as err:
_LOGGER.error("Failed to create motion filter timer: %s", err) _LOGGER.error("Failed to create motion filter timer: %s", err)
@@ -355,9 +451,7 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Starting off-delay timer for %d seconds", self.off_delay) _LOGGER.debug("Starting off-delay timer for %d seconds", self.off_delay)
try: try:
self._off_delay_timer = async_call_later( self._off_delay_timer = async_call_later(
self.hass, self.hass, self.off_delay, self._stop_recording_after_delay
self.off_delay,
self._stop_recording_after_delay
) )
except Exception as err: except Exception as err:
_LOGGER.error("Failed to create off-delay timer: %s", err) _LOGGER.error("Failed to create off-delay timer: %s", err)
@@ -389,7 +483,6 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
async def _start_recording(self, triggered_by): async def _start_recording(self, triggered_by):
"""Start recording the camera.""" """Start recording the camera."""
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)
if recorder_output is not None: if recorder_output is not None:
@@ -425,13 +518,11 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
filename = save_dir / f"{timestamp}.mp4" filename = save_dir / f"{timestamp}.mp4"
self._current_filename = str(filename) self._current_filename = str(filename)
# INFO: Начало записии
_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(
stream.async_record( stream.async_record(
video_path=str(filename), video_path=str(filename),
@@ -440,16 +531,17 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
) )
) )
# Ждём завершения
await self._current_record_task await self._current_record_task
await self._finalize_recording(str(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)
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(5)
await self._update_state(STATE_IDLE) 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.""" """Stop current recording."""
@@ -465,7 +557,6 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Removing recorder provider to stop recording") _LOGGER.debug("Removing recorder provider to stop recording")
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 to complete after stop signal")
try: try:
@@ -501,12 +592,10 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
"""Finalize recording and check file.""" """Finalize recording and check file."""
_LOGGER.debug("Finalizing recording: %s", filename) _LOGGER.debug("Finalizing recording: %s", filename)
# Переходим в finalizing
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)
# Ждём файл максимум 2 секунды с шагом 0.2с
for _ in range(10): for _ in range(10):
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
if filepath.is_file(): if filepath.is_file():
@@ -526,7 +615,6 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self._total_duration += int(duration) self._total_duration += int(duration)
self._total_size += size self._total_size += size
# INFO: Конец записии
_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,
@@ -535,100 +623,15 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
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_record_task = None
self._recording_start_time = None
self._current_filename = None
async def _finalize_recording(self, filename):
"""Finalize recording and check file."""
_LOGGER.debug("Finalizing recording: %s", filename)
# Переходим в finalizing
await self._update_state(STATE_FINALIZING, last_file_path=filename)
filepath = Path(filename)
# Ждём файл максимум 2 секунды с шагом 0.2с
for _ in range(10):
await asyncio.sleep(0.2)
if filepath.is_file():
size = filepath.stat().st_size
if size > _MIN_RECORDING_SIZE:
break
if filepath.is_file() and filepath.stat().st_size > _MIN_RECORDING_SIZE:
size = filepath.stat().st_size
if self._recording_start_time:
duration = (dt_util.utcnow() - self._recording_start_time).total_seconds()
else:
duration = self.max_duration
self._recordings_count += 1
self._total_duration += int(duration)
self._total_size += size
# INFO: Конец записии
_LOGGER.info(
"Recording completed: %s (%.1f sec, %d bytes). Total: %d recordings, %d sec, %d bytes",
filepath, duration, size,
self._recordings_count, self._total_duration, self._total_size
)
else:
_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: if not self._enabled:
_LOGGER.debug("Recording disabled during finalization, transitioning to disabled") _LOGGER.debug("Recording disabled 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")
await self._update_state(STATE_IDLE) await self._update_state(STATE_IDLE)
async def _cleanup_old_recordings(self, now=None):
"""Удалить записи старше retention_days."""
if self.retention_days == 0:
_LOGGER.debug("Retention days is 0, skipping cleanup")
return
save_dir = self._get_save_dir()
if not save_dir.exists():
_LOGGER.debug("Save directory does not exist: %s", save_dir)
return
_LOGGER.info("Starting cleanup of recordings older than %d days", self.retention_days)
cutoff_time = datetime.now() - timedelta(days=self.retention_days)
deleted_count = 0
deleted_size = 0
def _cleanup():
nonlocal deleted_count, deleted_size
try:
for file_path in save_dir.glob("*.mp4"):
if file_path.stat().st_mtime < cutoff_time.timestamp():
size = file_path.stat().st_size
file_path.unlink()
deleted_count += 1
deleted_size += size
_LOGGER.debug("Deleted old recording: %s", file_path.name)
except Exception as err:
_LOGGER.error("Error during cleanup: %s", err)
await self.hass.async_add_executor_job(_cleanup)
if deleted_count > 0:
_LOGGER.info(
"Cleanup completed: deleted %d files (%.1f MB)",
deleted_count,
deleted_size / (1024 * 1024)
)
else:
_LOGGER.debug("No old recordings found to delete")