Загрузить файлы в «custom_components/motion_recorder»
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""The Motion Recorder integration."""
|
||||
import logging
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN, CONF_LOG_LEVEL, DEFAULT_LOG_LEVEL
|
||||
from .coordinator import MotionRecorderCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = ["sensor", "switch"]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Motion Recorder from a config entry."""
|
||||
# Устанавливаем уровень логирования для всего домена
|
||||
log_level = _get_config_value(entry, CONF_LOG_LEVEL, DEFAULT_LOG_LEVEL)
|
||||
logging.getLogger(f"custom_components.{DOMAIN}").setLevel(getattr(logging, log_level, logging.INFO))
|
||||
_LOGGER.info("Motion Recorder integration loaded with log level: %s", log_level)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
coordinator = MotionRecorderCoordinator(hass, entry)
|
||||
await coordinator.async_setup()
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Добавляем listener для автоматической перезагрузки при изменении опций
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
await coordinator.async_shutdown()
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update - reload the integration."""
|
||||
_LOGGER.info("Options updated, reloading Motion Recorder integration")
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
def _get_config_value(entry: ConfigEntry, key: str, default=None):
|
||||
"""Get config value from options first, then from data, then default."""
|
||||
return entry.options.get(key, entry.data.get(key, default))
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Config flow for Motion Recorder."""
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
import voluptuous as vol
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_CAMERA_ENTITY,
|
||||
CONF_SAVE_PATH,
|
||||
CONF_FILENAME_TEMPLATE,
|
||||
CONF_MAX_DURATION,
|
||||
CONF_PREBUFFER,
|
||||
CONF_MOTION_SENSORS,
|
||||
CONF_MOTION_FILTER,
|
||||
CONF_OFF_DELAY,
|
||||
CONF_FORCE_STOP_SENSOR,
|
||||
CONF_FORCE_STOP_STATE,
|
||||
CONF_LOG_LEVEL,
|
||||
CONF_RETENTION_DAYS,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_SAVE_PATH,
|
||||
DEFAULT_FILENAME_TEMPLATE,
|
||||
DEFAULT_MAX_DURATION,
|
||||
DEFAULT_PREBUFFER,
|
||||
DEFAULT_MOTION_FILTER,
|
||||
DEFAULT_OFF_DELAY,
|
||||
DEFAULT_FORCE_STOP_STATE,
|
||||
DEFAULT_LOG_LEVEL,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
)
|
||||
|
||||
|
||||
# Список шаблонов имени файла (все с секундами для уникальности)
|
||||
FILENAME_TEMPLATE_OPTIONS = [
|
||||
selector.SelectOptionDict(value="%d-%m-%Y_%H-%M-%S", label="ДД-ММ-ГГГГ_ЧЧ-ММ-СС (02-07-2026_20-30-45)"),
|
||||
selector.SelectOptionDict(value="%Y-%m-%d_%H-%M-%S", label="ГГГГ-ММ-ДД_ЧЧ-ММ-СС (2026-07-02_20-30-45)"),
|
||||
selector.SelectOptionDict(value="%d.%m.%Y_%H-%M-%S", label="ДД.ММ.ГГГГ_ЧЧ-ММ-СС (02.07.2026_20-30-45)"),
|
||||
selector.SelectOptionDict(value="%Y%m%d_%H%M%S", label="ГГГГММДД_ЧЧММСС (20260702_203045)"),
|
||||
selector.SelectOptionDict(value="%d.%m.%Y_%H.%M.%S", label="ДД.ММ.ГГГГ_ЧЧ.ММ.СС (все точки)"),
|
||||
]
|
||||
|
||||
|
||||
class MotionRecorderConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Motion Recorder."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(user_input[CONF_CAMERA_ENTITY])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input.get("name", DEFAULT_NAME),
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required("name", default=DEFAULT_NAME): str,
|
||||
vol.Required(CONF_CAMERA_ENTITY): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="camera")
|
||||
),
|
||||
vol.Required(CONF_SAVE_PATH, default=DEFAULT_SAVE_PATH): str,
|
||||
vol.Required(CONF_FILENAME_TEMPLATE, default=DEFAULT_FILENAME_TEMPLATE): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=FILENAME_TEMPLATE_OPTIONS,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_MAX_DURATION, default=DEFAULT_MAX_DURATION): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=10, max=3600, step=1,
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
unit_of_measurement="сек",
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_PREBUFFER, default=DEFAULT_PREBUFFER): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=60, step=1,
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
unit_of_measurement="сек",
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_MOTION_SENSORS): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="binary_sensor", multiple=True)
|
||||
),
|
||||
vol.Required(CONF_MOTION_FILTER, default=DEFAULT_MOTION_FILTER): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=10, step=1,
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
unit_of_measurement="сек",
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_OFF_DELAY, default=DEFAULT_OFF_DELAY): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=60, step=1,
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
unit_of_measurement="сек",
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_RETENTION_DAYS, default=DEFAULT_RETENTION_DAYS): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=365, step=1,
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
unit_of_measurement="дн",
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_FORCE_STOP_SENSOR): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(
|
||||
domain=["binary_sensor", "switch", "input_boolean"],
|
||||
multiple=False,
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_FORCE_STOP_STATE, default=DEFAULT_FORCE_STOP_STATE): str,
|
||||
vol.Required(CONF_LOG_LEVEL, default=DEFAULT_LOG_LEVEL): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
selector.SelectOptionDict(value="DEBUG", label="DEBUG (подробно)"),
|
||||
selector.SelectOptionDict(value="INFO", label="INFO (обычный)"),
|
||||
selector.SelectOptionDict(value="WARNING", label="WARNING (только предупреждения)"),
|
||||
selector.SelectOptionDict(value="ERROR", label="ERROR (только ошибки)"),
|
||||
],
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get the options flow for this handler."""
|
||||
return MotionRecorderOptionsFlow()
|
||||
|
||||
|
||||
class MotionRecorderOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Handle options flow for Motion Recorder."""
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Manage the options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(
|
||||
CONF_FILENAME_TEMPLATE,
|
||||
default=self.config_entry.options.get(CONF_FILENAME_TEMPLATE,
|
||||
self.config_entry.data.get(CONF_FILENAME_TEMPLATE, DEFAULT_FILENAME_TEMPLATE))
|
||||
): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=FILENAME_TEMPLATE_OPTIONS,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_MAX_DURATION,
|
||||
default=self.config_entry.options.get(CONF_MAX_DURATION,
|
||||
self.config_entry.data.get(CONF_MAX_DURATION, DEFAULT_MAX_DURATION))
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=10, max=3600, step=1,
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
unit_of_measurement="сек",
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_PREBUFFER,
|
||||
default=self.config_entry.options.get(CONF_PREBUFFER,
|
||||
self.config_entry.data.get(CONF_PREBUFFER, DEFAULT_PREBUFFER))
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=60, step=1,
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
unit_of_measurement="сек",
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_MOTION_FILTER,
|
||||
default=self.config_entry.options.get(CONF_MOTION_FILTER,
|
||||
self.config_entry.data.get(CONF_MOTION_FILTER, DEFAULT_MOTION_FILTER))
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=10, step=1,
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
unit_of_measurement="сек",
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_OFF_DELAY,
|
||||
default=self.config_entry.options.get(CONF_OFF_DELAY,
|
||||
self.config_entry.data.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY))
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=60, step=1,
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
unit_of_measurement="сек",
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_RETENTION_DAYS,
|
||||
default=self.config_entry.options.get(CONF_RETENTION_DAYS,
|
||||
self.config_entry.data.get(CONF_RETENTION_DAYS, DEFAULT_RETENTION_DAYS))
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=365, step=1,
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
unit_of_measurement="дн",
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_LOG_LEVEL,
|
||||
default=self.config_entry.options.get(CONF_LOG_LEVEL,
|
||||
self.config_entry.data.get(CONF_LOG_LEVEL, DEFAULT_LOG_LEVEL))
|
||||
): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
selector.SelectOptionDict(value="DEBUG", label="DEBUG (подробно)"),
|
||||
selector.SelectOptionDict(value="INFO", label="INFO (обычный)"),
|
||||
selector.SelectOptionDict(value="WARNING", label="WARNING (только предупреждения)"),
|
||||
selector.SelectOptionDict(value="ERROR", label="ERROR (только ошибки)"),
|
||||
],
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Constants for Motion Recorder integration."""
|
||||
DOMAIN = "motion_recorder"
|
||||
|
||||
# Configuration keys
|
||||
CONF_CAMERA_ENTITY = "camera_entity"
|
||||
CONF_SAVE_PATH = "save_path"
|
||||
CONF_FILENAME_TEMPLATE = "filename_template"
|
||||
CONF_MAX_DURATION = "max_duration"
|
||||
CONF_PREBUFFER = "prebuffer"
|
||||
CONF_MOTION_SENSORS = "motion_sensors"
|
||||
CONF_MOTION_FILTER = "motion_filter"
|
||||
CONF_OFF_DELAY = "off_delay"
|
||||
CONF_FORCE_STOP_SENSOR = "force_stop_sensor"
|
||||
CONF_FORCE_STOP_STATE = "force_stop_state"
|
||||
CONF_LOG_LEVEL = "log_level"
|
||||
CONF_RETENTION_DAYS = "retention_days"
|
||||
|
||||
# Defaults
|
||||
DEFAULT_NAME = "Motion Recorder"
|
||||
DEFAULT_SAVE_PATH = "{camera_name}"
|
||||
DEFAULT_FILENAME_TEMPLATE = "%d-%m-%Y_%H-%M-%S"
|
||||
DEFAULT_MAX_DURATION = 60
|
||||
DEFAULT_PREBUFFER = 0
|
||||
DEFAULT_MOTION_FILTER = 0
|
||||
DEFAULT_OFF_DELAY = 10
|
||||
DEFAULT_FORCE_STOP_STATE = "off"
|
||||
DEFAULT_LOG_LEVEL = "INFO"
|
||||
DEFAULT_RETENTION_DAYS = 7
|
||||
|
||||
# Sensor states
|
||||
STATE_IDLE = "idle"
|
||||
STATE_DETECTING = "detecting"
|
||||
STATE_RECORDING = "recording"
|
||||
STATE_DELAYING = "delaying"
|
||||
STATE_FINALIZING = "finalizing"
|
||||
STATE_COMPLETED = "completed"
|
||||
STATE_ERROR = "error"
|
||||
STATE_DISABLED = "disabled"
|
||||
@@ -0,0 +1,634 @@
|
||||
"""Coordinator for Motion Recorder."""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
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.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_CAMERA_ENTITY,
|
||||
CONF_SAVE_PATH,
|
||||
CONF_FILENAME_TEMPLATE,
|
||||
CONF_MAX_DURATION,
|
||||
CONF_PREBUFFER,
|
||||
CONF_MOTION_SENSORS,
|
||||
CONF_MOTION_FILTER,
|
||||
CONF_OFF_DELAY,
|
||||
CONF_FORCE_STOP_SENSOR,
|
||||
CONF_FORCE_STOP_STATE,
|
||||
CONF_RETENTION_DAYS,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
STATE_IDLE,
|
||||
STATE_DETECTING,
|
||||
STATE_RECORDING,
|
||||
STATE_DELAYING,
|
||||
STATE_FINALIZING,
|
||||
STATE_COMPLETED,
|
||||
STATE_ERROR,
|
||||
STATE_DISABLED,
|
||||
)
|
||||
|
||||
try:
|
||||
from homeassistant.components.stream.const import RECORDER_PROVIDER as _RECORDER_PROVIDER
|
||||
except ImportError:
|
||||
_RECORDER_PROVIDER = "recorder"
|
||||
|
||||
_MIN_RECORDING_SIZE = 1024
|
||||
|
||||
# Глобальный логгер для всего модуля
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_config_value(entry, key, default=None):
|
||||
"""Get config value from options first, then from data, then default."""
|
||||
return entry.options.get(key, entry.data.get(key, default))
|
||||
|
||||
|
||||
def _mkdir_parents(p: Path):
|
||||
"""Path.mkdir с keyword args для executor."""
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class MotionRecorderCoordinator(DataUpdateCoordinator):
|
||||
"""Motion Recorder coordinator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry) -> None:
|
||||
"""Initialize coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"Motion Recorder {entry.data.get(CONF_CAMERA_ENTITY)}",
|
||||
)
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.camera_entity = entry.data[CONF_CAMERA_ENTITY]
|
||||
|
||||
# Читаем настройки из options с fallback на data
|
||||
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.max_duration = _get_config_value(entry, CONF_MAX_DURATION, 30)
|
||||
self.prebuffer = _get_config_value(entry, CONF_PREBUFFER, 0)
|
||||
self.motion_sensors = _get_config_value(entry, CONF_MOTION_SENSORS, [])
|
||||
self.motion_filter = _get_config_value(entry, CONF_MOTION_FILTER, 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_state = _get_config_value(entry, CONF_FORCE_STOP_STATE, "off")
|
||||
self.retention_days = _get_config_value(entry, CONF_RETENTION_DAYS, DEFAULT_RETENTION_DAYS)
|
||||
|
||||
# State management
|
||||
self._state = STATE_IDLE
|
||||
self._attributes = {}
|
||||
|
||||
# Enabled state
|
||||
self._enabled = True
|
||||
|
||||
# Listeners
|
||||
self._unsub_motion_listeners = []
|
||||
self._unsub_force_stop_listener = None
|
||||
self._cleanup_unsub = None
|
||||
|
||||
# Timers
|
||||
self._motion_detect_timer = None
|
||||
self._off_delay_timer = None
|
||||
|
||||
# Recording
|
||||
self._current_stream = None
|
||||
self._current_record_task = None
|
||||
self._recording_start_time = None
|
||||
self._current_filename = None
|
||||
|
||||
# Statistics
|
||||
self._recordings_count = 0
|
||||
self._total_duration = 0
|
||||
self._total_size = 0
|
||||
|
||||
def _get_media_base_path(self) -> Path:
|
||||
"""Получить базовый путь к медиа."""
|
||||
media_dirs = self.hass.config.media_dirs
|
||||
if media_dirs:
|
||||
if "media" in media_dirs:
|
||||
return Path(media_dirs["media"])
|
||||
if "local" in media_dirs:
|
||||
return Path(media_dirs["local"])
|
||||
first_key = next(iter(media_dirs))
|
||||
return Path(media_dirs[first_key])
|
||||
return Path(self.hass.config.path("media"))
|
||||
|
||||
def _get_save_dir(self) -> Path:
|
||||
"""Получить полную папку для сохранения записей."""
|
||||
camera_name = self.camera_entity.split(".")[1]
|
||||
save_path = self.save_path.format(camera_name=camera_name)
|
||||
|
||||
if Path(save_path).is_absolute():
|
||||
return Path(save_path)
|
||||
|
||||
return self._get_media_base_path() / save_path
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return current state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def attributes(self):
|
||||
"""Return current attributes with statistics."""
|
||||
attrs = self._attributes.copy()
|
||||
attrs["recordings_count"] = self._recordings_count
|
||||
attrs["total_duration"] = self._total_duration
|
||||
attrs["total_size"] = self._total_size
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""Return if recording is enabled."""
|
||||
return self._enabled
|
||||
|
||||
async def set_enabled(self, enabled: bool) -> None:
|
||||
"""Set enabled state."""
|
||||
self._enabled = enabled
|
||||
_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)
|
||||
|
||||
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)
|
||||
await self._update_state(STATE_IDLE)
|
||||
await self._start_listening()
|
||||
|
||||
# Запускаем ежедневную очистку в 3:00 ночи
|
||||
self._cleanup_unsub = async_track_time_change(
|
||||
self.hass,
|
||||
self._cleanup_old_recordings,
|
||||
hour=3,
|
||||
minute=0,
|
||||
second=0,
|
||||
)
|
||||
_LOGGER.info("Daily cleanup scheduled at 03:00")
|
||||
|
||||
async def async_shutdown(self):
|
||||
"""Shut down the coordinator."""
|
||||
_LOGGER.info("Shutting down Motion Recorder coordinator")
|
||||
await self._stop_listening()
|
||||
|
||||
if self._cleanup_unsub:
|
||||
self._cleanup_unsub()
|
||||
self._cleanup_unsub = None
|
||||
|
||||
if self._current_stream:
|
||||
await self._stop_recording()
|
||||
|
||||
async def _update_state(self, state, **attributes):
|
||||
"""Update state and attributes."""
|
||||
old_state = self._state
|
||||
self._state = state
|
||||
self._attributes.update(attributes)
|
||||
_LOGGER.debug("State changed: %s → %s", old_state, state)
|
||||
self.async_set_updated_data({
|
||||
"state": state,
|
||||
"attributes": self.attributes
|
||||
})
|
||||
|
||||
async def _start_listening(self):
|
||||
"""Start listening to motion sensors."""
|
||||
_LOGGER.info("Starting to listen to motion sensors: %s", self.motion_sensors)
|
||||
for sensor in self.motion_sensors:
|
||||
unsub = async_track_state_change_event(
|
||||
self.hass, [sensor], self._motion_state_changed
|
||||
)
|
||||
self._unsub_motion_listeners.append(unsub)
|
||||
|
||||
if self.force_stop_sensor:
|
||||
_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.hass, [self.force_stop_sensor], self._force_stop_state_changed
|
||||
)
|
||||
|
||||
async def _stop_listening(self):
|
||||
"""Stop listening to sensors."""
|
||||
for unsub in self._unsub_motion_listeners:
|
||||
unsub()
|
||||
self._unsub_motion_listeners = []
|
||||
|
||||
if self._unsub_force_stop_listener:
|
||||
self._unsub_force_stop_listener()
|
||||
self._unsub_force_stop_listener = None
|
||||
|
||||
@callback
|
||||
def _motion_state_changed(self, event):
|
||||
"""Handle motion sensor state change — ONLY on edges (fronts)."""
|
||||
old_state = event.data.get("old_state")
|
||||
new_state = event.data.get("new_state")
|
||||
|
||||
if not new_state:
|
||||
return
|
||||
|
||||
sensor_entity = event.data.get("entity_id")
|
||||
old_s = old_state.state if old_state else None
|
||||
new_s = new_state.state
|
||||
|
||||
# ИГНОРИРУЕМ события во время финализации
|
||||
if self._state in (STATE_FINALIZING, STATE_COMPLETED):
|
||||
_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())
|
||||
|
||||
@callback
|
||||
def _force_stop_state_changed(self, event):
|
||||
"""Handle force stop sensor state change."""
|
||||
new_state = event.data.get("new_state")
|
||||
if not new_state:
|
||||
return
|
||||
|
||||
if new_state.state == self.force_stop_state:
|
||||
_LOGGER.info("Force stop triggered by %s", event.data.get("entity_id"))
|
||||
self.hass.async_create_task(self._force_stop_recording())
|
||||
|
||||
def _is_any_motion_active(self) -> bool:
|
||||
"""Check if any motion sensor is currently in 'on' state."""
|
||||
for sensor in self.motion_sensors:
|
||||
state = self.hass.states.get(sensor)
|
||||
if state and state.state == "on":
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _handle_motion_detected(self, triggered_by):
|
||||
"""Handle motion detection with filter."""
|
||||
if not self._enabled:
|
||||
_LOGGER.debug("Recording disabled, ignoring motion")
|
||||
return
|
||||
|
||||
_LOGGER.debug("Motion detected by %s, current state: %s", triggered_by, self._state)
|
||||
|
||||
if self._state == STATE_RECORDING:
|
||||
_LOGGER.debug("Already recording, updating last motion time")
|
||||
self._attributes["last_motion_time"] = dt_util.utcnow().isoformat()
|
||||
return
|
||||
|
||||
if self._state == STATE_DETECTING:
|
||||
_LOGGER.debug("Already detecting, ignoring")
|
||||
return
|
||||
|
||||
if self._state == STATE_DELAYING:
|
||||
_LOGGER.info("Motion returned during off-delay, cancelling delay and continuing recording")
|
||||
if self._off_delay_timer:
|
||||
_LOGGER.debug("Cancelling off-delay timer")
|
||||
self._off_delay_timer()
|
||||
self._off_delay_timer = None
|
||||
await self._update_state(STATE_RECORDING)
|
||||
return
|
||||
|
||||
if self._off_delay_timer:
|
||||
_LOGGER.debug("Cancelling off-delay timer")
|
||||
self._off_delay_timer()
|
||||
self._off_delay_timer = None
|
||||
|
||||
await self._update_state(STATE_DETECTING, triggered_by=triggered_by)
|
||||
|
||||
if self.motion_filter > 0:
|
||||
_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
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Failed to create motion filter timer: %s", err)
|
||||
await self._start_recording_after_filter(None)
|
||||
else:
|
||||
await self._start_recording_after_filter(None)
|
||||
|
||||
async def _start_recording_after_filter(self, _=None):
|
||||
"""Start recording after motion filter."""
|
||||
_LOGGER.debug("Starting recording after filter")
|
||||
self._motion_detect_timer = None
|
||||
triggered_by = self._attributes.get("triggered_by")
|
||||
await self._start_recording(triggered_by)
|
||||
|
||||
async def _handle_motion_stopped(self):
|
||||
"""Handle motion stopped with off-delay."""
|
||||
_LOGGER.debug("Motion stopped, current state: %s", self._state)
|
||||
|
||||
if self._is_any_motion_active():
|
||||
_LOGGER.debug("Some motion sensor still active, ignoring stop")
|
||||
return
|
||||
|
||||
_LOGGER.debug("All motion sensors are off")
|
||||
|
||||
if self._state == STATE_RECORDING:
|
||||
_LOGGER.debug("Transitioning to DELAYING state")
|
||||
await self._update_state(STATE_DELAYING)
|
||||
|
||||
if self._off_delay_timer:
|
||||
_LOGGER.debug("Cancelling old off-delay timer")
|
||||
self._off_delay_timer()
|
||||
self._off_delay_timer = None
|
||||
|
||||
if self.off_delay > 0:
|
||||
_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
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Failed to create off-delay timer: %s", err)
|
||||
await self._stop_recording_after_delay(None)
|
||||
else:
|
||||
_LOGGER.debug("No off-delay, stopping immediately")
|
||||
await self._stop_recording_after_delay(None)
|
||||
|
||||
elif self._state == STATE_DETECTING:
|
||||
if self._motion_detect_timer:
|
||||
_LOGGER.debug("Cancelling motion filter timer")
|
||||
self._motion_detect_timer()
|
||||
self._motion_detect_timer = None
|
||||
await self._update_state(STATE_IDLE)
|
||||
|
||||
async def _stop_recording_after_delay(self, _=None):
|
||||
"""Stop recording after off-delay."""
|
||||
_LOGGER.debug("Off-delay timer expired")
|
||||
self._off_delay_timer = None
|
||||
|
||||
if self._is_any_motion_active():
|
||||
_LOGGER.info("Motion is still active — continuing recording")
|
||||
await self._update_state(STATE_RECORDING)
|
||||
return
|
||||
|
||||
_LOGGER.info("No motion detected, stopping recording")
|
||||
await self._stop_recording()
|
||||
|
||||
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:
|
||||
_LOGGER.warning("Recording already in progress, ignoring new request")
|
||||
return
|
||||
|
||||
self._recording_start_time = dt_util.utcnow()
|
||||
|
||||
await self._update_state(
|
||||
STATE_RECORDING,
|
||||
triggered_by=triggered_by,
|
||||
recording_start_time=self._recording_start_time.isoformat()
|
||||
)
|
||||
|
||||
component = self.hass.data.get("camera")
|
||||
if not component:
|
||||
raise Exception("Camera component not loaded")
|
||||
|
||||
camera = component.get_entity(self.camera_entity)
|
||||
if not camera:
|
||||
raise Exception(f"Camera {self.camera_entity} not found")
|
||||
|
||||
stream = await camera.async_create_stream()
|
||||
if not stream:
|
||||
raise Exception(f"Could not create stream for {self.camera_entity}")
|
||||
|
||||
self._current_stream = stream
|
||||
|
||||
save_dir = self._get_save_dir()
|
||||
await self.hass.async_add_executor_job(_mkdir_parents, save_dir)
|
||||
|
||||
timestamp = datetime.now().strftime(self.filename_template)
|
||||
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),
|
||||
duration=self.max_duration,
|
||||
lookback=self.prebuffer,
|
||||
)
|
||||
)
|
||||
|
||||
# Ждём завершения
|
||||
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)
|
||||
await self._update_state(STATE_IDLE)
|
||||
|
||||
async def _stop_recording(self):
|
||||
"""Stop current recording."""
|
||||
_LOGGER.debug("Stop recording called, current_stream: %s", self._current_stream is not None)
|
||||
|
||||
if not self._current_stream:
|
||||
_LOGGER.warning("No current stream, nothing to stop")
|
||||
return
|
||||
|
||||
try:
|
||||
recorder_output = self._current_stream.outputs().get(_RECORDER_PROVIDER)
|
||||
if recorder_output:
|
||||
_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:
|
||||
await asyncio.wait_for(self._current_record_task, timeout=10.0)
|
||||
_LOGGER.debug("Record task completed after stop")
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.warning("Record task did not complete within timeout, cancelling")
|
||||
self._current_record_task.cancel()
|
||||
try:
|
||||
await self._current_record_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
_LOGGER.debug("Recording stopped successfully")
|
||||
else:
|
||||
_LOGGER.warning("No recorder output found")
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error stopping 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 _force_stop_recording(self):
|
||||
"""Force stop recording."""
|
||||
_LOGGER.info("Force stop recording called")
|
||||
await self._stop_recording()
|
||||
|
||||
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
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "motion_recorder",
|
||||
"name": "Motion Recorder",
|
||||
"version": "1.0.0",
|
||||
"documentation": "https://github.com/sirmrak/motion_recorder",
|
||||
"issue_tracker": "https://github.com/sirmrak/motion_recorder/issues",
|
||||
"dependencies": ["camera", "stream"],
|
||||
"codeowners": ["@sirmrak"],
|
||||
"requirements": [],
|
||||
"iot_class": "local_push",
|
||||
"config_flow": true
|
||||
}
|
||||
Reference in New Issue
Block a user