3 Commits
Author SHA1 Message Date
semantic-release b256cd886e chore(release): v1.1.4
Update Version / Update Version (push) Successful in 5s
2026-07-24 10:58:31 +00:00
sirmrak 81f176ef2b Merge pull request 'fix: фиксы и оптимизации' (#1) from dev into main
Update Version / Update Version (push) Successful in 6s
Reviewed-on: #1
2026-07-24 13:58:23 +03:00
sirmrak 5e3e149c37 fix: фиксы и оптимизации
- Фикс зависания: `async_create_stream()` обёрнут в `wait_for` с таймаутом; вокруг ожидания задачи записи обрабатывается `CancelledError`; при выключении/force-stop зависшие задачи гасятся в единой точке.
- Хелперы `_is_blocked()`, `_idle_or_disabled_state()`, `_force_disable()` заменили 5 дублей проверок и два почти идентичных тела `set_enabled(False)` / `_activate_force_stop`.
- Кэш флага `_control_should_be_on` в управлении устройствами: опрос `states.get` и вызовы сервисов только при смене логического флага.
- Магические числа вынесены в константы модуля.
- В очистке один `stat()` на файл и `dt_util.now()` вместо naive-времени.
- Убрана двойная публикация в `set_enabled`; атрибут `last_motion_time` теперь публикуется сразу.
- Ранний выход из `_motion_state_changed` при блокировке, чтобы не плодить задачи вхолостую.
2026-07-24 13:52:54 +03:00
3 changed files with 271 additions and 184 deletions
+8
View File
@@ -2,6 +2,14 @@
<!-- version list --> <!-- version list -->
## v1.1.4 (2026-07-24)
### Bug Fixes
- Фиксы и оптимизации
([`5e3e149`](https://git.darkmode.duckdns.org/sirmrak/motion_recorder/commit/5e3e149c37ac708ac7751f052a29985b7443fe54))
## v1.1.3 (2026-07-17) ## v1.1.3 (2026-07-17)
### Bug Fixes ### Bug Fixes
+262 -183
View File
@@ -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,35 +177,66 @@ 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:
# Останавливаем запись если идёт _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:
# Включаем только если не заблокированы force_stop await self._update_state(STATE_IDLE)
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:
"""Отменить все активные таймеры.""" """Отменить все активные таймеры."""
@@ -189,35 +244,37 @@ 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, 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("Force stop blocking activated") await self._force_disable("force 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 disabled, ignoring motion") _LOGGER.debug("Recording blocked, 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,24 +539,23 @@ 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:
_LOGGER.debug("Cannot start recording: enabled=%s, force_stopped=%s",
self._enabled, self._force_stopped)
await self._update_state(STATE_IDLE if self._enabled else STATE_DISABLED)
return
_LOGGER.debug("Starting recording after filter")
self._motion_detect_timer = None self._motion_detect_timer = None
if self._is_blocked():
_LOGGER.debug("Cannot start recording after filter: blocked")
await self._update_state(self._idle_or_disabled_state())
return
_LOGGER.debug("Starting recording after filter")
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)
if self._is_any_motion_active(): if self._is_any_motion_active():
@@ -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:
_LOGGER.debug("Cannot stop recording: enabled=%s, force_stopped=%s",
self._enabled, self._force_stopped)
return
_LOGGER.debug("Off-delay timer expired")
self._off_delay_timer = None self._off_delay_timer = None
if self._is_blocked():
_LOGGER.debug("Recording blocked, skip stop after delay")
return
_LOGGER.debug("Off-delay timer expired")
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=%.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}")
@@ -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=%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(
@@ -619,21 +680,35 @@ 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(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=%.0fs)", _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(
except asyncio.CancelledError: self._current_record_task, timeout=_TASK_CANCEL_TIMEOUT
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:
@@ -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 stopped during finalization, transitioning to disabled") _LOGGER.debug("Blocked 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")
@@ -8,5 +8,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"issue_tracker": "https://github.com/sirmrak/motion_recorder/issues", "issue_tracker": "https://github.com/sirmrak/motion_recorder/issues",
"requirements": [], "requirements": [],
"version": "1.1.3" "version": "1.1.4"
} }