9 Commits
Author SHA1 Message Date
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
sirmrak e56f297f6d Update pyproject.toml
Update Version / Update Version (push) Successful in 5s
2026-07-24 13:42:20 +03:00
sirmrak 0860d19813 Update .github/workflows/release.yml
Update Version / Update Version (push) Successful in 5s
2026-07-24 13:41:28 +03:00
semantic-release 9fbb7cae18 chore(release): v1.1.3
Update Version / Update Version (push) Successful in 5s
2026-07-17 08:13:08 +00:00
sirmrak 0d64069548 fix: Test
Update Version / Update Version (push) Successful in 6s
2026-07-17 11:13:02 +03:00
sirmrak 03ea96b282 Обновить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 5s
2026-07-17 11:11:00 +03:00
sirmrak cac3274bbb Обновить pyproject.toml
Update Version / Update Version (push) Has been cancelled
2026-07-17 10:48:18 +03:00
semantic-release b6d87cb049 chore(release): v1.1.2
Update Version / Update Version (push) Successful in 5s
2026-07-11 15:33:27 +00:00
sirmrak dc85e69e28 fix: FST
Update Version / Update Version (push) Successful in 8s
При выключении switch запись полностью останавливается и не продолжается
При активации force_stop запись мгновенно останавливается и блокируется до деактивации
При деактивации force_stop система возвращается в нормальный режим
2026-07-11 18:33:15 +03:00
7 changed files with 358 additions and 167 deletions
+1 -24
View File
@@ -6,13 +6,11 @@ on:
- main - main
- master - master
env:
GIT_SSL_NO_VERIFY: true
jobs: jobs:
update-version: update-version:
name: Update Version name: Update Version
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Пропускаем запуск, если коммит сделал сам бот (избегаем бесконечного цикла)
if: github.actor != 'gitea-actions[bot]' && github.actor != 'Gitea Actions Bot' if: github.actor != 'gitea-actions[bot]' && github.actor != 'Gitea Actions Bot'
permissions: permissions:
contents: write contents: write
@@ -41,29 +39,8 @@ jobs:
env: env:
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }} GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
run: | run: |
echo "🧹 Сбрасываем конфликтующие переменные Gitea Actions..."
unset GITHUB_SERVER_URL unset GITHUB_SERVER_URL
unset GITHUB_API_URL unset GITHUB_API_URL
unset GITHUB_REPOSITORY unset GITHUB_REPOSITORY
echo "🔐 Записываем оригинальный Root CA от mkcert из секретов..."
echo "${{ secrets.LOCAL_ROOT_CA }}" > /tmp/rootCA.crt
# Проверка на случай, если секрет забыли настроить
if [ ! -s /tmp/rootCA.crt ]; then
echo "❌ Ошибка: Секрет LOCAL_ROOT_CA пуст. Проверьте настройки репозитория!"
exit 1
fi
echo "✅ Root CA успешно прочитан."
# Находим, где конкретно Python (библиотека certifi) хранит свои сертификаты
CERTIFI_PATH=$(python3 -c "import certifi; print(certifi.where())")
echo "Путь к bundle Python: $CERTIFI_PATH"
# Внедряем корневой сертификат mkcert в конец доверенного списка Python
cat /tmp/rootCA.crt >> "$CERTIFI_PATH"
echo "✅ Root CA от mkcert добавлен в доверенные для Python!"
echo "🚀 Запускаем semantic-release..."
semantic-release version semantic-release version
+32 -7
View File
@@ -4,10 +4,9 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch: # Позволяет запускать вручную workflow_dispatch:
jobs: jobs:
# Шаг 1: Валидация структуры HA интеграции
validate: validate:
name: Validate Integration name: Validate Integration
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -23,7 +22,6 @@ jobs:
with: with:
category: "integration" category: "integration"
# Шаг 2: Запуск тестов (если есть)
test: test:
name: Run Tests name: Run Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -51,7 +49,6 @@ jobs:
echo "No tests directory found, skipping tests" echo "No tests directory found, skipping tests"
fi fi
# Шаг 3: Проверка стиля кода
lint: lint:
name: Code Quality name: Code Quality
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -77,7 +74,6 @@ jobs:
run: | run: |
ruff check custom_components/motion_light/ || echo "Ruff issues found" ruff check custom_components/motion_light/ || echo "Ruff issues found"
# Шаг 4: Создание релиза (только если все проверки пройдены)
release: release:
name: Create Release name: Create Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -97,6 +93,15 @@ jobs:
TAG=${GITHUB_REF#refs/tags/} TAG=${GITHUB_REF#refs/tags/}
echo "tag=$TAG" >> $GITHUB_OUTPUT echo "tag=$TAG" >> $GITHUB_OUTPUT
# Проверяем, это beta или stable релиз
if [[ "$TAG" == *"beta"* ]]; then
echo "is_beta=true" >> $GITHUB_OUTPUT
PRERELEASE_FLAG="--prerelease"
else
echo "is_beta=false" >> $GITHUB_OUTPUT
PRERELEASE_FLAG=""
fi
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then if [ -z "$PREV_TAG" ]; then
@@ -115,18 +120,38 @@ jobs:
run: | run: |
TAG="${{ steps.tag.outputs.tag }}" TAG="${{ steps.tag.outputs.tag }}"
COMMITS="${{ steps.tag.outputs.commits }}" COMMITS="${{ steps.tag.outputs.commits }}"
IS_BETA="${{ steps.tag.outputs.is_beta }}"
echo "🚀 Создаём релиз $TAG в GitHub..." echo "🚀 Создаём релиз $TAG в GitHub..."
echo "Тип релиза: IS_BETA=$IS_BETA"
# Определяем заголовок
if [ "$IS_BETA" = "true" ]; then
TITLE="Beta Release $TAG"
PRERELEASE_FLAG="--prerelease"
else
TITLE="Release $TAG"
PRERELEASE_FLAG=""
fi
cat > /tmp/release_notes.md << EOF cat > /tmp/release_notes.md << EOF
## Изменения в версии $TAG ## $TITLE
$COMMITS $COMMITS
EOF EOF
# Создаём релиз
if [ -n "$PRERELEASE_FLAG" ]; then
gh release create "$TAG" \ gh release create "$TAG" \
--title "Release $TAG" \ --title "$TITLE" \
--notes-file /tmp/release_notes.md \
--target main \
--prerelease
else
gh release create "$TAG" \
--title "$TITLE" \
--notes-file /tmp/release_notes.md \ --notes-file /tmp/release_notes.md \
--target main --target main
fi
echo "✅ Релиз создан!" echo "✅ Релиз создан!"
+16
View File
@@ -2,6 +2,22 @@
<!-- version list --> <!-- version list -->
## v1.1.3 (2026-07-17)
### Bug Fixes
- Test
([`0d64069`](https://git.darkmode.duckdns.org/sirmrak/motion_recorder/commit/0d64069548d980814ae3403c2b6b43ac4491b918))
## v1.1.2 (2026-07-11)
### Bug Fixes
- FST
([`dc85e69`](https://192.168.200.208:3000/sirmrak/motion_recorder/commit/dc85e69e289cb2f13ddb0e51a98bfc207c73a9d6))
## v1.1.1 (2026-07-10) ## v1.1.1 (2026-07-10)
### Bug Fixes ### Bug Fixes
@@ -1,4 +1,4 @@
"""The Motion Recorder integration.""" """The Motion Recorder integration. Test"""
import logging import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
+290 -124
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,42 +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
# 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:
@@ -125,74 +151,130 @@ 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
attrs["total_size"] = self._total_size attrs["total_size"] = self._total_size
attrs["force_stopped"] = self._force_stopped
return attrs return attrs
@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:
if self._state == STATE_RECORDING: await self._force_disable("switch turned off")
await self._stop_recording() elif self._force_stopped:
_LOGGER.info("Cannot enable: force stop is active")
await self._update_state(STATE_DISABLED) await self._update_state(STATE_DISABLED)
else: else:
await self._update_state(STATE_IDLE) await self._update_state(STATE_IDLE)
self.async_set_updated_data({ async def _cancel_all_timers(self) -> None:
"state": self._state, """Отменить все активные таймеры."""
"attributes": self.attributes if self._motion_detect_timer:
}) _LOGGER.debug("Cancelling motion detect timer")
self._motion_detect_timer()
self._motion_detect_timer = None
if self._off_delay_timer:
_LOGGER.debug("Cancelling off-delay timer")
self._off_delay_timer()
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()
@@ -200,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]
@@ -215,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."""
@@ -277,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
@@ -286,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)
@@ -300,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(
@@ -315,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 = []
@@ -332,44 +425,64 @@ 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
if new_state.state == self.force_stop_state: new_s = new_state.state
_LOGGER.info("Force stop triggered by %s", event.data.get("entity_id")) sensor_entity = event.data.get("entity_id")
self.hass.async_create_task(self._force_stop_recording()) _LOGGER.debug("Force stop sensor %s changed to: %s", sensor_entity, new_s)
if new_s == self.force_stop_state:
_LOGGER.info("Force stop ACTIVATED by %s", sensor_entity)
self.hass.async_create_task(self._activate_force_stop())
else:
_LOGGER.info("Force stop DEACTIVATED by %s", sensor_entity)
self.hass.async_create_task(self._deactivate_force_stop())
async def _activate_force_stop(self) -> None:
"""Активировать блокировку force_stop."""
self._force_stopped = True
await self._force_disable("force stop sensor")
async def _deactivate_force_stop(self) -> None:
"""Снять блокировку force_stop."""
self._force_stopped = False
_LOGGER.info("Force stop blocking deactivated")
await self._update_state(self._idle_or_disabled_state())
# === Обработчики движения ===
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":
@@ -377,16 +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 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:
@@ -394,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()
@@ -422,14 +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.""" """Старт записи после отработки фильтра."""
_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._is_blocked():
_LOGGER.debug("Recording blocked, ignoring motion stopped")
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():
@@ -465,12 +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(STATE_IDLE) await self._update_state(self._idle_or_disabled_state())
async def _stop_recording_after_delay(self, _=None): async def _stop_recording_after_delay(self, _=None):
"""Stop recording after off-delay.""" """Остановка записи по истечении off-delay."""
_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")
@@ -480,8 +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 self._is_blocked():
_LOGGER.warning("Cannot start recording: blocked")
await self._update_state(self._idle_or_disabled_state())
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)
@@ -490,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")
@@ -505,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}")
@@ -514,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(
@@ -531,21 +680,35 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
) )
) )
# Ждём завершения с корректной обработкой отмены
try:
await self._current_record_task await self._current_record_task
except asyncio.CancelledError:
_LOGGER.info("Recording task was cancelled (stop requested)")
await self._finalize_recording(str(filename)) await self._finalize_recording(str(filename))
except asyncio.CancelledError:
_LOGGER.info("Recording start was cancelled")
if filename:
await self._finalize_recording(str(filename))
except Exception as err: except Exception as err:
_LOGGER.error("Error starting recording: %s", err, exc_info=True) _LOGGER.error("Error starting recording: %s", err, exc_info=True)
# При блокировке не мигаем error — сразу в спокойное состояние
if self._is_blocked():
await self._update_state(self._idle_or_disabled_state())
return
await self._update_state(STATE_ERROR, error_message=str(err)) await self._update_state(STATE_ERROR, error_message=str(err))
await asyncio.sleep(5) await asyncio.sleep(_ERROR_RECOVERY_DELAY)
if not self._enabled: await self._update_state(self._idle_or_disabled_state())
await self._update_state(STATE_DISABLED)
else:
await self._update_state(STATE_IDLE)
async def _stop_recording(self): async def _stop_recording(self):
"""Stop current recording.""" """Остановить текущую запись."""
_LOGGER.debug("Stop recording called, current_stream: %s", self._current_stream is not None) _LOGGER.debug(
"Stop recording called, stream=%s, task=%s",
self._current_stream is not None,
self._current_record_task is not None,
)
if not self._current_stream: if not self._current_stream:
_LOGGER.warning("No current stream, nothing to stop") _LOGGER.warning("No current stream, nothing to stop")
@@ -558,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(
self._current_record_task, timeout=_TASK_CANCEL_TIMEOUT
)
except (asyncio.CancelledError, asyncio.TimeoutError):
_LOGGER.warning("Record task cancel timed out")
except asyncio.CancelledError: except asyncio.CancelledError:
pass _LOGGER.debug("Record task was cancelled")
_LOGGER.debug("Recording stopped successfully") _LOGGER.debug("Recording stopped successfully")
else: else:
@@ -576,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: 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.""" """Принудительная остановка записи (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:
@@ -616,21 +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 self._is_blocked():
if not self._enabled: _LOGGER.debug("Blocked during finalization, transitioning to disabled")
_LOGGER.debug("Recording disabled during finalization, transitioning to disabled")
await self._update_state(STATE_DISABLED) await self._update_state(STATE_DISABLED)
else: else:
_LOGGER.debug("Transitioning to idle") _LOGGER.debug("Transitioning to idle")
@@ -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.1" "version": "1.1.3"
} }
+10 -3
View File
@@ -7,18 +7,25 @@ version_variables = [
commit_message = "chore(release): v{version}" commit_message = "chore(release): v{version}"
# ВАЖНО: не создаём релиз в Gitea (релиз будет в GitHub) # Отключаем создание релиза через API Gitea (релиз будет создаваться в GitHub)
upload_to_vcs_release = false upload_to_vcs_release = false
[tool.semantic_release.remote] [tool.semantic_release.remote]
type = "gitea" type = "gitea"
domain = "192.168.200.208:3000" domain = "git.darkmode.duckdns.org"
api_domain = "192.168.200.208:3000" api_domain = "git.darkmode.duckdns.org"
# Настройки для stable релизов (ветка main)
[tool.semantic_release.branches.main] [tool.semantic_release.branches.main]
match = "(main|master)" match = "(main|master)"
prerelease = false prerelease = false
# Настройки для beta релизов (ветка dev)
[tool.semantic_release.branches.dev]
match = "dev"
prerelease = true
prerelease_token = "beta"
[tool.semantic_release.commit_parser_options] [tool.semantic_release.commit_parser_options]
allowed_tags = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "revert"] allowed_tags = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "revert"]
minor_tags = ["feat"] minor_tags = ["feat"]