Обновить custom_components/motion_recorder/coordinator.py
This commit is contained in:
@@ -21,13 +21,15 @@ from .const import (
|
||||
CONF_FORCE_STOP_SENSOR,
|
||||
CONF_FORCE_STOP_STATE,
|
||||
CONF_RETENTION_DAYS,
|
||||
CONF_CONTROLLED_ENTITIES,
|
||||
CONF_CONTROL_STATES,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
DEFAULT_CONTROL_STATES,
|
||||
STATE_IDLE,
|
||||
STATE_DETECTING,
|
||||
STATE_RECORDING,
|
||||
STATE_DELAYING,
|
||||
STATE_FINALIZING,
|
||||
STATE_COMPLETED,
|
||||
STATE_ERROR,
|
||||
STATE_DISABLED,
|
||||
)
|
||||
@@ -39,7 +41,6 @@ except ImportError:
|
||||
|
||||
_MIN_RECORDING_SIZE = 1024
|
||||
|
||||
# Глобальный логгер для всего модуля
|
||||
_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.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
|
||||
self._state = STATE_IDLE
|
||||
self._attributes = {}
|
||||
@@ -153,21 +159,25 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.info("Recording %s", "enabled" if enabled else "disabled")
|
||||
|
||||
if not enabled:
|
||||
# Останавливаем текущую запись, если она идёт
|
||||
if self._state == STATE_RECORDING:
|
||||
await self._stop_recording()
|
||||
# Переходим в состояние "disabled"
|
||||
await self._update_state(STATE_DISABLED)
|
||||
else:
|
||||
# Возвращаемся в idle (только если сейчас disabled)
|
||||
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):
|
||||
"""Set up the coordinator."""
|
||||
_LOGGER.info("Setting up Motion Recorder coordinator for %s", self.camera_entity)
|
||||
_LOGGER.info("Settings: max_duration=%s, off_delay=%s, retention_days=%s",
|
||||
self.max_duration, self.off_delay, self.retention_days)
|
||||
_LOGGER.info(
|
||||
"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._start_listening()
|
||||
|
||||
@@ -190,6 +200,18 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
|
||||
self._cleanup_unsub()
|
||||
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:
|
||||
await self._stop_recording()
|
||||
|
||||
@@ -204,6 +226,85 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
|
||||
"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):
|
||||
"""Start listening to 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
|
||||
new_s = new_state.state
|
||||
|
||||
# ИГНОРИРУЕМ события во время финализации
|
||||
if self._state in (STATE_FINALIZING, STATE_COMPLETED):
|
||||
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)
|
||||
|
||||
# ФРОНТ ВВЕРХ
|
||||
if new_s == "on" and old_s != "on":
|
||||
_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))
|
||||
|
||||
# ФРОНТ ВНИЗ
|
||||
elif old_s == "on" and new_s != "on":
|
||||
_LOGGER.debug("Motion FRONT DOWN: %s (%s → %s)", sensor_entity, old_s, new_s)
|
||||
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)
|
||||
try:
|
||||
self._motion_detect_timer = async_call_later(
|
||||
self.hass,
|
||||
self.motion_filter,
|
||||
self._start_recording_after_filter
|
||||
self.hass, self.motion_filter, self._start_recording_after_filter
|
||||
)
|
||||
except Exception as 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)
|
||||
try:
|
||||
self._off_delay_timer = async_call_later(
|
||||
self.hass,
|
||||
self.off_delay,
|
||||
self._stop_recording_after_delay
|
||||
self.hass, self.off_delay, self._stop_recording_after_delay
|
||||
)
|
||||
except Exception as 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):
|
||||
"""Start recording the camera."""
|
||||
try:
|
||||
# Проверяем, есть ли уже активная запись
|
||||
if self._current_stream:
|
||||
recorder_output = self._current_stream.outputs().get(_RECORDER_PROVIDER)
|
||||
if recorder_output is not None:
|
||||
@@ -425,13 +518,11 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
|
||||
filename = save_dir / f"{timestamp}.mp4"
|
||||
self._current_filename = str(filename)
|
||||
|
||||
# INFO: Начало записии
|
||||
_LOGGER.info(
|
||||
"Recording started: %s (duration=%ds, lookback=%ds)",
|
||||
filename, self.max_duration, self.prebuffer
|
||||
)
|
||||
|
||||
# Запускаем запись
|
||||
self._current_record_task = asyncio.create_task(
|
||||
stream.async_record(
|
||||
video_path=str(filename),
|
||||
@@ -440,15 +531,16 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
|
||||
)
|
||||
)
|
||||
|
||||
# Ждём завершения
|
||||
await self._current_record_task
|
||||
|
||||
await self._finalize_recording(str(filename))
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error starting recording: %s", err, exc_info=True)
|
||||
await self._update_state(STATE_ERROR, error_message=str(err))
|
||||
await asyncio.sleep(5)
|
||||
if not self._enabled:
|
||||
await self._update_state(STATE_DISABLED)
|
||||
else:
|
||||
await self._update_state(STATE_IDLE)
|
||||
|
||||
async def _stop_recording(self):
|
||||
@@ -465,7 +557,6 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.debug("Removing recorder provider to stop recording")
|
||||
await self._current_stream.remove_provider(recorder_output)
|
||||
|
||||
# Ждём завершения задачи записии
|
||||
if self._current_record_task and not self._current_record_task.done():
|
||||
_LOGGER.debug("Waiting for record task to complete after stop signal")
|
||||
try:
|
||||
@@ -501,12 +592,10 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
|
||||
"""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():
|
||||
@@ -526,7 +615,6 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
|
||||
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,
|
||||
@@ -535,100 +623,15 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
|
||||
else:
|
||||
_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_record_task = None
|
||||
self._recording_start_time = None
|
||||
self._current_filename = None
|
||||
|
||||
# Проверяем, не был ли выключен переключатель во время записи
|
||||
if not self._enabled:
|
||||
_LOGGER.debug("Recording disabled during finalization, transitioning to disabled")
|
||||
await self._update_state(STATE_DISABLED)
|
||||
else:
|
||||
_LOGGER.debug("Transitioning to 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")
|
||||
Reference in New Issue
Block a user