30 Commits
Author SHA1 Message Date
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 5016165c10 Update .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 5s
2026-07-24 13:57:05 +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
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
semantic-release 495fbe5dce chore(release): v1.1.1
Update Version / Update Version (push) Successful in 5s
2026-07-10 09:25:11 +00:00
sirmrak 6ccca63270 fix: Оформление
Update Version / Update Version (push) Successful in 7s
2026-07-10 12:25:05 +03:00
sirmrak f9cbcfe7f6 Добавить .github/workflows/release.yml
Update Version / Update Version (push) Successful in 5s
2026-07-10 12:14:02 +03:00
sirmrak cdba65f5f7 Добавить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 5s
2026-07-10 12:12:55 +03:00
sirmrak c817a47e30 Добавить pyproject.toml 2026-07-10 12:11:38 +03:00
sirmrak 96ff3d4285 Обновить README.md 2026-07-08 13:29:45 +03:00
sirmrak 457ff1fa02 Обновить README.ru.md 2026-07-08 13:29:21 +03:00
sirmrak 33c170ca15 Обновить README.ru.md 2026-07-05 13:50:19 +03:00
sirmrak 2c473b157c Обновить README.md 2026-07-05 13:46:43 +03:00
sirmrak a1b63d2ee1 Обновить README.md 2026-07-05 13:46:11 +03:00
sirmrak 92ecd51e2e Обновить custom_components/motion_recorder/translations/ru.json 2026-07-05 13:36:31 +03:00
sirmrak e60a47ef48 Обновить custom_components/motion_recorder/translations/en.json 2026-07-05 13:36:10 +03:00
sirmrak ab3de76de5 Обновить custom_components/motion_recorder/__init__.py 2026-07-05 13:35:40 +03:00
sirmrak 84127a1229 Обновить custom_components/motion_recorder/config_flow.py 2026-07-05 13:34:29 +03:00
sirmrak 57d2441d3b Обновить custom_components/motion_recorder/strings.json 2026-07-05 13:33:56 +03:00
sirmrak ea1b6e3916 Обновить custom_components/motion_recorder/sensor.py 2026-07-05 13:33:27 +03:00
sirmrak 792a185204 Обновить custom_components/motion_recorder/manifest.json 2026-07-05 13:32:55 +03:00
sirmrak 015e3221df Обновить custom_components/motion_recorder/coordinator.py 2026-07-05 13:32:29 +03:00
sirmrak 02cb16325c Обновить custom_components/motion_recorder/const.py 2026-07-05 13:31:50 +03:00
15 changed files with 1861 additions and 1280 deletions
+46
View File
@@ -0,0 +1,46 @@
name: Update Version
on:
push:
branches:
- main
- master
- dev
jobs:
update-version:
name: Update Version
runs-on: ubuntu-latest
# Пропускаем запуск, если коммит сделал сам бот (избегаем бесконечного цикла)
if: github.actor != 'gitea-actions[bot]' && github.actor != 'Gitea Actions Bot'
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install python-semantic-release
- name: Configure Git
run: |
git config --local user.email "actions@gitea.local"
git config --local user.name "Gitea Actions Bot"
- name: Run semantic-release
env:
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
run: |
unset GITHUB_SERVER_URL
unset GITHUB_API_URL
unset GITHUB_REPOSITORY
semantic-release version
+157
View File
@@ -0,0 +1,157 @@
name: Test and Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
validate:
name: Validate Integration
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Hassfest Validation
uses: home-assistant/actions/hassfest@master
- name: HACS Validation
uses: hacs/action@main
with:
category: "integration"
test:
name: Run Tests
runs-on: ubuntu-latest
needs: validate
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-homeassistant-custom-component
if [ -f requirements_test.txt ]; then pip install -r requirements_test.txt; fi
- name: Run tests
run: |
if [ -d "tests" ]; then
pytest tests/ --cov=custom_components/motion_light --cov-report=xml
else
echo "No tests directory found, skipping tests"
fi
lint:
name: Code Quality
runs-on: ubuntu-latest
needs: validate
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install linters
run: |
pip install black ruff mypy
- name: Check code formatting with black
run: |
black --check custom_components/motion_light/ || echo "Code formatting issues found"
- name: Check code with ruff
run: |
ruff check custom_components/motion_light/ || echo "Ruff issues found"
release:
name: Create Release
runs-on: ubuntu-latest
needs: [validate, test, lint]
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get tag info
id: tag
run: |
TAG=${GITHUB_REF#refs/tags/}
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 "")
if [ -z "$PREV_TAG" ]; then
COMMITS=$(git log --pretty=format:"- %s" --reverse)
else
COMMITS=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s" --reverse)
fi
echo "commits<<EOF" >> $GITHUB_OUTPUT
echo "$COMMITS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.tag.outputs.tag }}"
COMMITS="${{ steps.tag.outputs.commits }}"
IS_BETA="${{ steps.tag.outputs.is_beta }}"
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
## $TITLE
$COMMITS
EOF
# Создаём релиз
if [ -n "$PRERELEASE_FLAG" ]; then
gh release create "$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 \
--target main
fi
echo "✅ Релиз создан!"
+34
View File
@@ -0,0 +1,34 @@
# CHANGELOG
<!-- 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)
### Bug Fixes
- Оформление
([`6ccca63`](https://192.168.200.208:3000/sirmrak/motion_recorder/commit/6ccca6327021b53a8cd46e62bb0d740f0a609043))
## v1.1.0 (2026-07-05)
## v1.0.0 (2026-07-03)
- Initial Release
+32 -3
View File
@@ -1,4 +1,4 @@
# Motion Recorder for Home Assistant # <img src="custom_components/motion_recorder/brand/icon.png" width="64"> </img> Motion Recorder for Home Assistant
English | [Русский](README.ru.md) English | [Русский](README.ru.md)
## A powerful Home Assistant integration for recording camera streams based on motion detection with advanced filtering and automated management. ## A powerful Home Assistant integration for recording camera streams based on motion detection with advanced filtering and automated management.
@@ -19,6 +19,8 @@ English | [Русский](README.ru.md)
- **Force stop sensor** - Stop recording based on any entity state - **Force stop sensor** - Stop recording based on any entity state
- **Enable/disable switch** - Control recording with a toggle - **Enable/disable switch** - Control recording with a toggle
- **Status sensor** - Monitor recording state with beautiful icons - **Status sensor** - Monitor recording state with beautiful icons
- **Smart device control** - Automatically turn on/off lights or switches based on recording state
- **Auto-cleanup** - Delete old recordings by retention policy (daily at 03:00)
- **Multi-language support** - English and Russian translations - **Multi-language support** - English and Russian translations
- **Hot reload** - Settings apply immediately without restart - **Hot reload** - Settings apply immediately without restart
@@ -66,6 +68,8 @@ When adding the integration, you'll configure:
| **Force Stop Sensor** | Entity to force stop recording | - | - | | **Force Stop Sensor** | Entity to force stop recording | - | - |
| **Force Stop State** | State value to trigger stop | `off` | - | | **Force Stop State** | State value to trigger stop | `off` | - |
| **Retention Days** | Auto-delete recordings older than | 7 days | 0-365 days | | **Retention Days** | Auto-delete recordings older than | 7 days | 0-365 days |
| **Controlled Devices** | Lights/switches to auto-control | - | - |
| **Control States** | States when devices should be ON | `recording` | Multi-select |
| **Log Level** | Logging verbosity | INFO | - | | **Log Level** | Logging verbosity | INFO | - |
### Options (Post-Setup) ### Options (Post-Setup)
@@ -77,6 +81,8 @@ After initial setup, you can modify these settings via **Settings → Devices &
- Motion filter - Motion filter
- Off delay - Off delay
- Retention days - Retention days
- Controlled Devices
- Control States
- Log level - Log level
Changes apply immediately without restart. Changes apply immediately without restart.
@@ -217,6 +223,29 @@ Files older than the retention period are automatically deleted daily at 03:00.
Cleanup runs in the background and doesn't affect recording performance. Cleanup runs in the background and doesn't affect recording performance.
## Smart Device Control
Motion Recorder can automatically control lights and switches based on its current state.
### How It Works
1. Select one or more `light` or `switch` entities in settings
2. Choose which states should trigger the devices (e.g., `recording`, `delaying`)
3. When the integration enters a selected state → devices turn ON
4. When it leaves that state → devices turn OFF (only if integration turned them on)
### Safety Features
- **Non-intrusive**: If you manually turn on a light, the integration won't turn it off
- **Manual override respected**: If you turn off a controlled light during recording, it stays off
- **Shutdown cleanup**: All integration-controlled devices are turned off when HA restarts or integration reloads
### Example Use Cases
- Turn on porch light only while recording is active
- Activate alarm indicator LED during motion detection
- Turn on indoor lighting when camera detects movement at night
## Advanced Features ## Advanced Features
### Multiple Motion Sensors ### Multiple Motion Sensors
@@ -308,6 +337,6 @@ This project is licensed under the MIT License - see the LICENSE file for detail
--- ---
**Version**: 1.0.0 **Version**: 1.1.0
**Last Updated**: 2026-07-03 **Last Updated**: 2026-07-05
**Compatible with**: Home Assistant 2024.6+ **Compatible with**: Home Assistant 2024.6+
+32 -3
View File
@@ -1,4 +1,4 @@
# Motion Recorder для Home Assistant # <img src="custom_components/motion_recorder/brand/icon.png" width="64"> </img> Motion Recorder для Home Assistant
[English](README.md) | Русский [English](README.md) | Русский
Мощная интеграция Home Assistant для записи камер по датчику движения с продвинутой фильтрацией и автоматическим управлением. Мощная интеграция Home Assistant для записи камер по датчику движения с продвинутой фильтрацией и автоматическим управлением.
@@ -19,6 +19,8 @@
- 🔌 **Датчик принудительной остановки** - Остановка записи по состоянию любой сущности - 🔌 **Датчик принудительной остановки** - Остановка записи по состоянию любой сущности
- 🎛️ **Переключатель включения/выключения** - Управление записью одним кликом - 🎛️ **Переключатель включения/выключения** - Управление записью одним кликом
- **Сенсор статуса** - Мониторинг состояния записи с красивыми иконками - **Сенсор статуса** - Мониторинг состояния записи с красивыми иконками
- 💡 **Умное управление устройствами** - Автоматическое включение/выключение света и переключателей по состоянию записи
- 🗑️ **Автоочистка** - Удаление старых записей по политике хранения (ежедневно в 03:00)
- 🌍 **Многоязычность** - Переводы на русский и английский языки - 🌍 **Многоязычность** - Переводы на русский и английский языки
- **Горячая перезагрузка** - Настройки применяются сразу без перезапуска - **Горячая перезагрузка** - Настройки применяются сразу без перезапуска
@@ -66,6 +68,8 @@
| **Датчик принуд. остановки** | Сущность для принудительной остановки | - | - | | **Датчик принуд. остановки** | Сущность для принудительной остановки | - | - |
| **Состояние остановки** | Значение состояния для запуска остановки | `off` | - | | **Состояние остановки** | Значение состояния для запуска остановки | `off` | - |
| **Дни хранения** | Автоудаление записей старше | 7 дней | 0-365 дней | | **Дни хранения** | Автоудаление записей старше | 7 дней | 0-365 дней |
| **Управляемые устройства** | Светильники/переключатели для автоуправления | - | - |
| **Состояния включения** | Состояния, при которых устройства включаются | `recording` | Множественный выбор |
| **Уровень логирования** | Детализация логов | INFO | - | | **Уровень логирования** | Детализация логов | INFO | - |
### Опции (после настройки) ### Опции (после настройки)
@@ -77,6 +81,8 @@
- Фильтр движения - Фильтр движения
- Задержка остановки - Задержка остановки
- Дни хранения - Дни хранения
- Управляемые устройства
- Состояния включения
- Уровень логирования - Уровень логирования
Изменения применяются сразу без перезапуска. Изменения применяются сразу без перезапуска.
@@ -217,6 +223,29 @@ automation:
Очистка работает в фоновом режиме и не влияет на производительность записи. Очистка работает в фоновом режиме и не влияет на производительность записи.
## Умное управление устройствами
Motion Recorder может автоматически управлять светильниками и переключателями в зависимости от текущего состояния.
### Как это работает
1. Выберите одну или несколько сущностей `light` или `switch` в настройках
2. Отметьте состояния, при которых устройства должны включаться (например, `recording`, `delaying`)
3. При переходе в выбранное состояние → устройства включаются
4. При выходе из состояния → устройства выключаются (только если интеграция их включала)
### Безопасность
- **Не мешает пользователю**: Если вы вручную включили свет, интеграция его не выключит
- **Ручное управление приоритетно**: Если вы выключили управляемый свет во время записи, он останется выключенным
- **Очистка при остановке**: Все устройства, включённые интеграцией, выключаются при перезагрузке HA или перезагрузке интеграции
### Примеры использования
- Включать уличный фонарь только во время активной записи
- Активировать индикатор тревоги при обнаружении движения
- Включать освещение в помещении при детекции движения камерой ночью
## Продвинутые возможности ## Продвинутые возможности
### Несколько датчиков движения ### Несколько датчиков движения
@@ -308,6 +337,6 @@ logger:
--- ---
**Версия**: 1.0.0 **Версия**: 1.1.0
**Последнее обновление**: 2026-07-03 **Последнее обновление**: 2026-07-05
**Совместимо с**: Home Assistant 2024.6+ **Совместимо с**: Home Assistant 2024.6+
@@ -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
@@ -11,9 +11,13 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = ["sensor", "switch"] PLATFORMS = ["sensor", "switch"]
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))
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Motion Recorder from a config entry.""" """Set up Motion Recorder from a config entry."""
# Устанавливаем уровень логирования для всего домена
log_level = _get_config_value(entry, CONF_LOG_LEVEL, DEFAULT_LOG_LEVEL) 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)) 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) _LOGGER.info("Motion Recorder integration loaded with log level: %s", log_level)
@@ -27,7 +31,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Добавляем listener для автоматической перезагрузки при изменении опций
entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True return True
@@ -50,8 +53,3 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non
"""Handle options update - reload the integration.""" """Handle options update - reload the integration."""
_LOGGER.info("Options updated, reloading Motion Recorder integration") _LOGGER.info("Options updated, reloading Motion Recorder integration")
await hass.config_entries.async_reload(entry.entry_id) 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))
@@ -18,6 +18,8 @@ from .const import (
CONF_FORCE_STOP_STATE, CONF_FORCE_STOP_STATE,
CONF_LOG_LEVEL, CONF_LOG_LEVEL,
CONF_RETENTION_DAYS, CONF_RETENTION_DAYS,
CONF_CONTROLLED_ENTITIES,
CONF_CONTROL_STATES,
DEFAULT_NAME, DEFAULT_NAME,
DEFAULT_SAVE_PATH, DEFAULT_SAVE_PATH,
DEFAULT_FILENAME_TEMPLATE, DEFAULT_FILENAME_TEMPLATE,
@@ -28,10 +30,16 @@ from .const import (
DEFAULT_FORCE_STOP_STATE, DEFAULT_FORCE_STOP_STATE,
DEFAULT_LOG_LEVEL, DEFAULT_LOG_LEVEL,
DEFAULT_RETENTION_DAYS, DEFAULT_RETENTION_DAYS,
DEFAULT_CONTROL_STATES,
STATE_IDLE,
STATE_DETECTING,
STATE_RECORDING,
STATE_DELAYING,
STATE_FINALIZING,
STATE_ERROR,
STATE_DISABLED,
) )
# Список шаблонов имени файла (все с секундами для уникальности)
FILENAME_TEMPLATE_OPTIONS = [ FILENAME_TEMPLATE_OPTIONS = [
selector.SelectOptionDict(value="%d-%m-%Y_%H-%M-%S", label="ДД-ММ-ГГГГ_ЧЧ-ММ-СС (02-07-2026_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="ГГГГ-ММ-ДД_ЧЧ-ММ-СС (2026-07-02_20-30-45)"), selector.SelectOptionDict(value="%Y-%m-%d_%H-%M-%S", label="ГГГГ-ММ-ДД_ЧЧ-ММ-СС (2026-07-02_20-30-45)"),
@@ -40,6 +48,16 @@ FILENAME_TEMPLATE_OPTIONS = [
selector.SelectOptionDict(value="%d.%m.%Y_%H.%M.%S", label="ДД.ММ.ГГГГ_ЧЧ.ММ.СС (все точки)"), selector.SelectOptionDict(value="%d.%m.%Y_%H.%M.%S", label="ДД.ММ.ГГГГ_ЧЧ.ММ.СС (все точки)"),
] ]
CONTROL_STATE_OPTIONS = [
selector.SelectOptionDict(value=STATE_IDLE, label="Ожидание (idle)"),
selector.SelectOptionDict(value=STATE_DETECTING, label="Обнаружение (detecting)"),
selector.SelectOptionDict(value=STATE_RECORDING, label="Запись (recording)"),
selector.SelectOptionDict(value=STATE_DELAYING, label="Задержка (delaying)"),
selector.SelectOptionDict(value=STATE_FINALIZING, label="Завершение (finalizing)"),
selector.SelectOptionDict(value=STATE_ERROR, label="Ошибка (error)"),
selector.SelectOptionDict(value=STATE_DISABLED, label="Отключено (disabled)"),
]
class MotionRecorderConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class MotionRecorderConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Motion Recorder.""" """Handle a config flow for Motion Recorder."""
@@ -104,13 +122,6 @@ class MotionRecorderConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
unit_of_measurement="сек", 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( vol.Optional(CONF_FORCE_STOP_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig( selector.EntitySelectorConfig(
domain=["binary_sensor", "switch", "input_boolean"], domain=["binary_sensor", "switch", "input_boolean"],
@@ -118,6 +129,26 @@ class MotionRecorderConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
), ),
vol.Required(CONF_FORCE_STOP_STATE, default=DEFAULT_FORCE_STOP_STATE): str, vol.Required(CONF_FORCE_STOP_STATE, default=DEFAULT_FORCE_STOP_STATE): str,
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_CONTROLLED_ENTITIES, default=[]): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=["light", "switch"],
multiple=True,
)
),
vol.Optional(CONF_CONTROL_STATES, default=DEFAULT_CONTROL_STATES): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONTROL_STATE_OPTIONS,
multiple=True,
mode=selector.SelectSelectorMode.LIST,
)
),
vol.Required(CONF_LOG_LEVEL, default=DEFAULT_LOG_LEVEL): selector.SelectSelector( vol.Required(CONF_LOG_LEVEL, default=DEFAULT_LOG_LEVEL): selector.SelectSelector(
selector.SelectSelectorConfig( selector.SelectSelectorConfig(
options=[ options=[
@@ -216,6 +247,31 @@ class MotionRecorderOptionsFlow(config_entries.OptionsFlow):
unit_of_measurement="дн", unit_of_measurement="дн",
) )
), ),
vol.Optional(
CONF_CONTROLLED_ENTITIES,
default=self.config_entry.options.get(
CONF_CONTROLLED_ENTITIES,
self.config_entry.data.get(CONF_CONTROLLED_ENTITIES, [])
)
): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=["light", "switch"],
multiple=True,
)
),
vol.Optional(
CONF_CONTROL_STATES,
default=self.config_entry.options.get(
CONF_CONTROL_STATES,
self.config_entry.data.get(CONF_CONTROL_STATES, DEFAULT_CONTROL_STATES)
)
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONTROL_STATE_OPTIONS,
multiple=True,
mode=selector.SelectSelectorMode.LIST,
)
),
vol.Required( vol.Required(
CONF_LOG_LEVEL, CONF_LOG_LEVEL,
default=self.config_entry.options.get(CONF_LOG_LEVEL, default=self.config_entry.options.get(CONF_LOG_LEVEL,
+13 -1
View File
@@ -14,6 +14,8 @@ CONF_FORCE_STOP_SENSOR = "force_stop_sensor"
CONF_FORCE_STOP_STATE = "force_stop_state" CONF_FORCE_STOP_STATE = "force_stop_state"
CONF_LOG_LEVEL = "log_level" CONF_LOG_LEVEL = "log_level"
CONF_RETENTION_DAYS = "retention_days" CONF_RETENTION_DAYS = "retention_days"
CONF_CONTROLLED_ENTITIES = "controlled_entities"
CONF_CONTROL_STATES = "control_states"
# Defaults # Defaults
DEFAULT_NAME = "Motion Recorder" DEFAULT_NAME = "Motion Recorder"
@@ -26,6 +28,7 @@ DEFAULT_OFF_DELAY = 10
DEFAULT_FORCE_STOP_STATE = "off" DEFAULT_FORCE_STOP_STATE = "off"
DEFAULT_LOG_LEVEL = "INFO" DEFAULT_LOG_LEVEL = "INFO"
DEFAULT_RETENTION_DAYS = 7 DEFAULT_RETENTION_DAYS = 7
DEFAULT_CONTROL_STATES = ["recording"]
# Sensor states # Sensor states
STATE_IDLE = "idle" STATE_IDLE = "idle"
@@ -33,6 +36,15 @@ STATE_DETECTING = "detecting"
STATE_RECORDING = "recording" STATE_RECORDING = "recording"
STATE_DELAYING = "delaying" STATE_DELAYING = "delaying"
STATE_FINALIZING = "finalizing" STATE_FINALIZING = "finalizing"
STATE_COMPLETED = "completed"
STATE_ERROR = "error" STATE_ERROR = "error"
STATE_DISABLED = "disabled" STATE_DISABLED = "disabled"
ALL_STATES = [
STATE_IDLE,
STATE_DETECTING,
STATE_RECORDING,
STATE_DELAYING,
STATE_FINALIZING,
STATE_ERROR,
STATE_DISABLED,
]
+374 -205
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 (
@@ -21,13 +25,15 @@ from .const import (
CONF_FORCE_STOP_SENSOR, CONF_FORCE_STOP_SENSOR,
CONF_FORCE_STOP_STATE, CONF_FORCE_STOP_STATE,
CONF_RETENTION_DAYS, CONF_RETENTION_DAYS,
CONF_CONTROLLED_ENTITIES,
CONF_CONTROL_STATES,
DEFAULT_RETENTION_DAYS, DEFAULT_RETENTION_DAYS,
DEFAULT_CONTROL_STATES,
STATE_IDLE, STATE_IDLE,
STATE_DETECTING, STATE_DETECTING,
STATE_RECORDING, STATE_RECORDING,
STATE_DELAYING, STATE_DELAYING,
STATE_FINALIZING, STATE_FINALIZING,
STATE_COMPLETED,
STATE_ERROR, STATE_ERROR,
STATE_DISABLED, STATE_DISABLED,
) )
@@ -37,27 +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,
@@ -67,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, [])
@@ -77,37 +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
)
# State management # Управляемые устройства
self.controlled_entities = _get_config_value(entry, CONF_CONTROLLED_ENTITIES, [])
self.control_states = _get_config_value(
entry, CONF_CONTROL_STATES, DEFAULT_CONTROL_STATES
)
self._we_turned_on: set[str] = set()
# Кэш логического флага «устройства должны быть включены»,
# чтобы не опрашивать состояния при каждом переходе состояния
self._control_should_be_on: bool | None = None
# Состояние
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:
@@ -119,70 +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:
# Останавливаем текущую запись, если она идёт await self._force_disable("switch turned off")
if self._state == STATE_RECORDING: elif self._force_stopped:
await self._stop_recording() _LOGGER.info("Cannot enable: force stop is active")
# Переходим в состояние "disabled"
await self._update_state(STATE_DISABLED) await self._update_state(STATE_DISABLED)
else: else:
# Возвращаемся в idle (только если сейчас disabled)
if self._state == STATE_DISABLED:
await self._update_state(STATE_IDLE) await self._update_state(STATE_IDLE)
async def _cancel_all_timers(self) -> None:
"""Отменить все активные таймеры."""
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("Settings: max_duration=%s, off_delay=%s, retention_days=%s", _LOGGER.info(
self.max_duration, self.off_delay, self.retention_days) "Settings: max_duration=%s, off_delay=%s, retention_days=%s, "
"controlled_entities=%s, control_states=%s",
self.max_duration, self.off_delay, self.retention_days,
self.controlled_entities, self.control_states,
)
await self._update_state(STATE_IDLE) await self._update_state(STATE_IDLE)
await self._start_listening() await self._start_listening()
# Запускаем ежедневную очистку в 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()
@@ -190,22 +282,121 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self._cleanup_unsub() self._cleanup_unsub()
self._cleanup_unsub = None self._cleanup_unsub = None
# Гасим устройства, которые включали сами
for entity_id in list(self._we_turned_on):
try:
domain = entity_id.split(".")[0]
_LOGGER.info("Shutdown: turning off %s", entity_id)
await self.hass.services.async_call(
domain, "turn_off", {"entity_id": entity_id}, blocking=False,
)
except Exception as err:
_LOGGER.error("Error turning off %s on shutdown: %s", entity_id, err)
self._we_turned_on.clear()
if self._current_stream: if self._current_stream:
await self._stop_recording() await self._stop_recording()
# === Публикация состояния и управление устройствами ===
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:
await self._update_controlled_entities()
async def _update_controlled_entities(self) -> None:
"""Синхронизировать управляемые устройства с состоянием.
Реагирует только на изменение логического флага should_be_on,
чтобы не опрашивать состояния сущностей при каждом переходе.
"""
if not self.controlled_entities:
return
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:
try:
domain = entity_id.split(".")[0]
current = self.hass.states.get(entity_id)
if current is None:
_LOGGER.warning("Controlled entity %s not found, skipping", entity_id)
continue
is_on = current.state == "on"
if should_be_on and not is_on:
_LOGGER.info("Turning ON %s (state=%s)", entity_id, self._state)
await self.hass.services.async_call(
domain, "turn_on", {"entity_id": entity_id}, blocking=False,
)
self._we_turned_on.add(entity_id)
elif not should_be_on and is_on and entity_id in self._we_turned_on:
_LOGGER.info("Turning OFF %s (state=%s)", entity_id, self._state)
await self.hass.services.async_call(
domain, "turn_off", {"entity_id": entity_id}, blocking=False,
)
self._we_turned_on.discard(entity_id)
except Exception as err:
_LOGGER.error("Error controlling %s: %s", entity_id, err, exc_info=True)
# === Очистка старых записей ===
async def _cleanup_old_recordings(self, now=None):
"""Удалить записи старше retention_days."""
if self.retention_days == 0:
_LOGGER.debug("Retention days is 0, skipping cleanup")
return
save_dir = self._get_save_dir()
if not save_dir.exists():
_LOGGER.debug("Save directory does not exist: %s", save_dir)
return
_LOGGER.info("Starting cleanup of recordings older than %d days", self.retention_days)
cutoff_ts = (dt_util.now() - timedelta(days=self.retention_days)).timestamp()
deleted_count = 0
deleted_size = 0
def _cleanup():
nonlocal deleted_count, deleted_size
try:
for file_path in save_dir.glob("*.mp4"):
st = file_path.stat() # один stat на файл
if st.st_mtime < cutoff_ts:
file_path.unlink()
deleted_count += 1
deleted_size += st.st_size
_LOGGER.debug("Deleted old recording: %s", file_path.name)
except Exception as err:
_LOGGER.error("Error during cleanup: %s", err)
await self.hass.async_add_executor_job(_cleanup)
if deleted_count > 0:
_LOGGER.info(
"Cleanup completed: deleted %d files (%.1f MB)",
deleted_count, deleted_size / (1024 * 1024),
)
else:
_LOGGER.debug("No old recordings found to delete")
# === Подписки на сенсоры ===
async def _start_listening(self): async def _start_listening(self):
"""Start listening to motion sensors.""" """Подписаться на сенсоры движения и 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(
@@ -214,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 = []
@@ -231,47 +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 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) _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":
@@ -279,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:
@@ -296,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()
@@ -315,9 +530,7 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Starting motion filter timer for %d seconds", self.motion_filter) _LOGGER.debug("Starting motion filter timer for %d seconds", self.motion_filter)
try: try:
self._motion_detect_timer = async_call_later( self._motion_detect_timer = async_call_later(
self.hass, self.hass, self.motion_filter, self._start_recording_after_filter
self.motion_filter,
self._start_recording_after_filter
) )
except Exception as err: except Exception as err:
_LOGGER.error("Failed to create motion filter timer: %s", err) _LOGGER.error("Failed to create motion filter timer: %s", err)
@@ -326,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():
@@ -355,9 +577,7 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Starting off-delay timer for %d seconds", self.off_delay) _LOGGER.debug("Starting off-delay timer for %d seconds", self.off_delay)
try: try:
self._off_delay_timer = async_call_later( self._off_delay_timer = async_call_later(
self.hass, self.hass, self.off_delay, self._stop_recording_after_delay
self.off_delay,
self._stop_recording_after_delay
) )
except Exception as err: except Exception as err:
_LOGGER.error("Failed to create off-delay timer: %s", err) _LOGGER.error("Failed to create off-delay timer: %s", err)
@@ -371,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")
@@ -386,10 +610,17 @@ 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)
if recorder_output is not None: if recorder_output is not None:
@@ -397,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")
@@ -412,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}")
@@ -421,17 +663,15 @@ 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)
# INFO: Начало записии
_LOGGER.info( _LOGGER.info(
"Recording started: %s (duration=%ds, lookback=%ds)", "Recording started: %s (duration=%ds, lookback=%ds)",
filename, self.max_duration, self.prebuffer filename, self.max_duration, self.prebuffer,
) )
# Запускаем запись
self._current_record_task = asyncio.create_task( self._current_record_task = asyncio.create_task(
stream.async_record( stream.async_record(
video_path=str(filename), video_path=str(filename),
@@ -440,20 +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)
await self._update_state(STATE_IDLE) await self._update_state(self._idle_or_disabled_state())
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")
@@ -465,19 +720,24 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Removing recorder provider to stop recording") _LOGGER.debug("Removing recorder provider to stop recording")
await self._current_stream.remove_provider(recorder_output) await self._current_stream.remove_provider(recorder_output)
# Ждём завершения задачи записии
if self._current_record_task and not self._current_record_task.done(): if self._current_record_task and not self._current_record_task.done():
_LOGGER.debug("Waiting for record task to complete after stop signal") _LOGGER.debug("Waiting for record task (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:
@@ -485,38 +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)
# Переходим в finalizing
await self._update_state(STATE_FINALIZING, last_file_path=filename) await self._update_state(STATE_FINALIZING, last_file_path=filename)
filepath = Path(filename) filepath = Path(filename)
for _ in range(_FINALIZE_POLL_TRIES):
# Ждём файл максимум 2 секунды с шагом 0.2с await asyncio.sleep(_FINALIZE_POLL_STEP)
for _ in range(10):
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:
@@ -526,109 +780,24 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self._total_duration += int(duration) self._total_duration += int(duration)
self._total_size += size self._total_size += size
# INFO: Конец записии
_LOGGER.info( _LOGGER.info(
"Recording completed: %s (%.1f sec, %d bytes). Total: %d recordings, %d sec, %d bytes", "Recording completed: %s (%.1f sec, %d bytes). "
"Total: %d recordings, %d sec, %d bytes",
filepath, duration, size, filepath, duration, size,
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
async def _finalize_recording(self, filename): if self._is_blocked():
"""Finalize recording and check file.""" _LOGGER.debug("Blocked during finalization, transitioning to disabled")
_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) await self._update_state(STATE_DISABLED)
else: else:
_LOGGER.debug("Transitioning to idle") _LOGGER.debug("Transitioning to idle")
await self._update_state(STATE_IDLE) await self._update_state(STATE_IDLE)
async def _cleanup_old_recordings(self, now=None):
"""Удалить записи старше retention_days."""
if self.retention_days == 0:
_LOGGER.debug("Retention days is 0, skipping cleanup")
return
save_dir = self._get_save_dir()
if not save_dir.exists():
_LOGGER.debug("Save directory does not exist: %s", save_dir)
return
_LOGGER.info("Starting cleanup of recordings older than %d days", self.retention_days)
cutoff_time = datetime.now() - timedelta(days=self.retention_days)
deleted_count = 0
deleted_size = 0
def _cleanup():
nonlocal deleted_count, deleted_size
try:
for file_path in save_dir.glob("*.mp4"):
if file_path.stat().st_mtime < cutoff_time.timestamp():
size = file_path.stat().st_size
file_path.unlink()
deleted_count += 1
deleted_size += size
_LOGGER.debug("Deleted old recording: %s", file_path.name)
except Exception as err:
_LOGGER.error("Error during cleanup: %s", err)
await self.hass.async_add_executor_job(_cleanup)
if deleted_count > 0:
_LOGGER.info(
"Cleanup completed: deleted %d files (%.1f MB)",
deleted_count,
deleted_size / (1024 * 1024)
)
else:
_LOGGER.debug("No old recordings found to delete")
@@ -1,12 +1,12 @@
{ {
"domain": "motion_recorder", "domain": "motion_recorder",
"name": "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"], "codeowners": ["@sirmrak"],
"requirements": [], "config_flow": true,
"dependencies": ["camera", "stream"],
"documentation": "https://github.com/sirmrak/motion_recorder",
"iot_class": "local_push", "iot_class": "local_push",
"config_flow": true "issue_tracker": "https://github.com/sirmrak/motion_recorder/issues",
"requirements": [],
"version": "1.1.3"
} }
+1 -4
View File
@@ -6,7 +6,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN, CONF_CAMERA_ENTITY from .const import DOMAIN
async def async_setup_entry( async def async_setup_entry(
@@ -30,7 +30,6 @@ class MotionRecorderStatusSensor(CoordinatorEntity, SensorEntity):
self._attr_name = f"{entry.title} Статус" self._attr_name = f"{entry.title} Статус"
self._attr_translation_key = "motion_recorder_status" self._attr_translation_key = "motion_recorder_status"
# Добавляем информацию об устройстве
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)}, identifiers={(DOMAIN, entry.entry_id)},
name=entry.title, name=entry.title,
@@ -38,14 +37,12 @@ class MotionRecorderStatusSensor(CoordinatorEntity, SensorEntity):
model="Camera Recording Integration", model="Camera Recording Integration",
) )
# Icon mapping
self._icon_map = { self._icon_map = {
"idle": "mdi:sleep", "idle": "mdi:sleep",
"detecting": "mdi:motion-sensor", "detecting": "mdi:motion-sensor",
"recording": "mdi:record-rec", "recording": "mdi:record-rec",
"delaying": "mdi:timer-sand", "delaying": "mdi:timer-sand",
"finalizing": "mdi:file-check", "finalizing": "mdi:file-check",
"completed": "mdi:check-circle",
"error": "mdi:alert-circle", "error": "mdi:alert-circle",
"disabled": "mdi:video-off", "disabled": "mdi:video-off",
} }
+12 -4
View File
@@ -14,9 +14,11 @@
"motion_sensors": "Датчики движения", "motion_sensors": "Датчики движения",
"motion_filter": "Фильтр обнаружения движения (сек, 0 = без фильтрации)", "motion_filter": "Фильтр обнаружения движения (сек, 0 = без фильтрации)",
"off_delay": "Задержка после пропадания движения (сек, 0 = без задержки)", "off_delay": "Задержка после пропадания движения (сек, 0 = без задержки)",
"retention_days": "Хранить записи (дней, 0 = не удалять)",
"force_stop_sensor": "Датчик принудительной остановки (binary_sensor, switch, input_boolean)", "force_stop_sensor": "Датчик принудительной остановки (binary_sensor, switch, input_boolean)",
"force_stop_state": "Состояние для остановки", "force_stop_state": "Состояние для остановки",
"retention_days": "Хранить записи (дней, 0 = не удалять)",
"controlled_entities": "Управляемые устройства (light/switch)",
"control_states": "Включать при состояниях",
"log_level": "Уровень логирования" "log_level": "Уровень логирования"
}, },
"data_description": { "data_description": {
@@ -24,14 +26,16 @@
"camera_entity": "Выберите камеру из списка доступных", "camera_entity": "Выберите камеру из списка доступных",
"save_path": "Папка для сохранения записей относительно /media. Поддерживаются переменные, например camera_name", "save_path": "Папка для сохранения записей относительно /media. Поддерживаются переменные, например camera_name",
"filename_template": "Выберите формат даты и времени для имён файлов записей. Все форматы содержат секунды, чтобы избежать перезаписи файлов при нескольких срабатываниях в одну минуту", "filename_template": "Выберите формат даты и времени для имён файлов записей. Все форматы содержат секунды, чтобы избежать перезаписи файлов при нескольких срабатываниях в одну минуту",
"max_duration": "Максимальное время одной записии (10-3600 секунд). Если движение не прекратится, запись остановится по таймауту", "max_duration": "Максимальное время одной записи (10-3600 секунд). Если движение не прекратится, запись остановится по таймауту",
"prebuffer": "Сколько секунд до обнаружения движения включить в запись. Работает только если стрим уже активен", "prebuffer": "Сколько секунд до обнаружения движения включить в запись. Работает только если стрим уже активен",
"motion_sensors": "Можно выбрать несколько датчиков. Запись начнётся при сработке любого из них", "motion_sensors": "Можно выбрать несколько датчиков. Запись начнётся при сработке любого из них",
"motion_filter": "Задержка перед началом записи. Если движение пропадёт раньше — запись не начнётся (защита от ложных срабатываний)", "motion_filter": "Задержка перед началом записи. Если движение пропадёт раньше — запись не начнётся (защита от ложных срабатываний)",
"off_delay": "Сколько секунд ждать после пропадания движения перед остановкой записи. Если движение вернётся — таймер сбросится", "off_delay": "Сколько секунд ждать после пропадания движения перед остановкой записи. Если движение вернётся — таймер сбросится",
"retention_days": "Автоматически удалять записи старше указанного количества дней. 0 = не удалять никогда. Очистка выполняется ежедневно в 3:00",
"force_stop_sensor": "Необязательно. Сущность, при переходе в указанное состояние запись немедленно остановится", "force_stop_sensor": "Необязательно. Сущность, при переходе в указанное состояние запись немедленно остановится",
"force_stop_state": "Значение состояния датчика принудительной остановки (обычно 'off' или 'on')", "force_stop_state": "Значение состояния датчика принудительной остановки (обычно 'off' или 'on')",
"retention_days": "Автоматически удалять записи старше указанного количества дней. 0 = не удалять никогда. Очистка выполняется ежедневно в 3:00",
"controlled_entities": "Список светильников или переключателей, которые будут автоматически включаться/выключаться в зависимости от состояния интеграции",
"control_states": "Отметьте состояния, при которых управляемые устройства должны быть включены. В остальных состояниях они будут выключены (только если были включены интеграцией)",
"log_level": "Уровень детализации логов. DEBUG — для отладки, INFO — обычный режим" "log_level": "Уровень детализации логов. DEBUG — для отладки, INFO — обычный режим"
} }
} }
@@ -52,15 +56,19 @@
"motion_filter": "Фильтр обнаружения движения (сек, 0 = без фильтрации)", "motion_filter": "Фильтр обнаружения движения (сек, 0 = без фильтрации)",
"off_delay": "Задержка после пропадания движения (сек, 0 = без задержки)", "off_delay": "Задержка после пропадания движения (сек, 0 = без задержки)",
"retention_days": "Хранить записи (дней, 0 = не удалять)", "retention_days": "Хранить записи (дней, 0 = не удалять)",
"controlled_entities": "Управляемые устройства (light/switch)",
"control_states": "Включать при состояниях",
"log_level": "Уровень логирования" "log_level": "Уровень логирования"
}, },
"data_description": { "data_description": {
"filename_template": "Выберите формат даты и времени для имён файлов записей", "filename_template": "Выберите формат даты и времени для имён файлов записей",
"max_duration": "Максимальное время одной записии (10-3600 секунд)", "max_duration": "Максимальное время одной записи (10-3600 секунд)",
"prebuffer": "Сколько секунд до обнаружения движения включить в запись", "prebuffer": "Сколько секунд до обнаружения движения включить в запись",
"motion_filter": "Задержка перед началом записи для защиты от ложных срабатываний", "motion_filter": "Задержка перед началом записи для защиты от ложных срабатываний",
"off_delay": "Сколько секунд ждать после пропадания движения перед остановкой записи", "off_delay": "Сколько секунд ждать после пропадания движения перед остановкой записи",
"retention_days": "Автоматически удалять записи старше указанного количества дней. 0 = не удалять никогда", "retention_days": "Автоматически удалять записи старше указанного количества дней. 0 = не удалять никогда",
"controlled_entities": "Список светильников или переключателей для автоматического управления",
"control_states": "Состояния, при которых устройства должны быть включены",
"log_level": "Уровень детализации логов" "log_level": "Уровень детализации логов"
} }
} }
@@ -14,9 +14,11 @@
"motion_sensors": "Motion sensors", "motion_sensors": "Motion sensors",
"motion_filter": "Motion detection filter (seconds, 0 = no filter)", "motion_filter": "Motion detection filter (seconds, 0 = no filter)",
"off_delay": "Delay after motion stops (seconds, 0 = no delay)", "off_delay": "Delay after motion stops (seconds, 0 = no delay)",
"retention_days": "Keep recordings (days, 0 = never delete)",
"force_stop_sensor": "Force stop sensor (binary_sensor, switch, input_boolean)", "force_stop_sensor": "Force stop sensor (binary_sensor, switch, input_boolean)",
"force_stop_state": "Stop state value", "force_stop_state": "Stop state value",
"retention_days": "Keep recordings (days, 0 = never delete)",
"controlled_entities": "Controlled devices (light/switch)",
"control_states": "Turn on at states",
"log_level": "Log level" "log_level": "Log level"
}, },
"data_description": { "data_description": {
@@ -29,9 +31,11 @@
"motion_sensors": "You can select multiple sensors. Recording will start when any of them triggers", "motion_sensors": "You can select multiple sensors. Recording will start when any of them triggers",
"motion_filter": "Delay before starting recording. If motion stops earlier — recording will not start (false trigger protection)", "motion_filter": "Delay before starting recording. If motion stops earlier — recording will not start (false trigger protection)",
"off_delay": "How many seconds to wait after motion stops before stopping recording. If motion returns — timer resets", "off_delay": "How many seconds to wait after motion stops before stopping recording. If motion returns — timer resets",
"retention_days": "Automatically delete recordings older than specified days. 0 = never delete. Cleanup runs daily at 03:00",
"force_stop_sensor": "Optional. Entity that will immediately stop recording when it reaches the specified state", "force_stop_sensor": "Optional. Entity that will immediately stop recording when it reaches the specified state",
"force_stop_state": "State value of the force stop sensor (usually 'off' or 'on')", "force_stop_state": "State value of the force stop sensor (usually 'off' or 'on')",
"retention_days": "Automatically delete recordings older than specified days. 0 = never delete. Cleanup runs daily at 03:00",
"controlled_entities": "List of lights or switches that will be automatically turned on/off depending on the integration state",
"control_states": "Select states when controlled devices should be turned on. In other states they will be turned off (only if they were turned on by the integration)",
"log_level": "Log detail level. DEBUG — for debugging, INFO — normal mode" "log_level": "Log detail level. DEBUG — for debugging, INFO — normal mode"
} }
} }
@@ -52,6 +56,8 @@
"motion_filter": "Motion detection filter (seconds, 0 = no filter)", "motion_filter": "Motion detection filter (seconds, 0 = no filter)",
"off_delay": "Delay after motion stops (seconds, 0 = no delay)", "off_delay": "Delay after motion stops (seconds, 0 = no delay)",
"retention_days": "Keep recordings (days, 0 = never delete)", "retention_days": "Keep recordings (days, 0 = never delete)",
"controlled_entities": "Controlled devices (light/switch)",
"control_states": "Turn on at states",
"log_level": "Log level" "log_level": "Log level"
}, },
"data_description": { "data_description": {
@@ -61,6 +67,8 @@
"motion_filter": "Delay before starting recording for false trigger protection", "motion_filter": "Delay before starting recording for false trigger protection",
"off_delay": "How many seconds to wait after motion stops before stopping recording", "off_delay": "How many seconds to wait after motion stops before stopping recording",
"retention_days": "Automatically delete recordings older than specified days. 0 = never delete", "retention_days": "Automatically delete recordings older than specified days. 0 = never delete",
"controlled_entities": "List of lights or switches for automatic control",
"control_states": "States when devices should be turned on",
"log_level": "Log detail level" "log_level": "Log detail level"
} }
} }
@@ -76,7 +84,6 @@
"recording": "Recording", "recording": "Recording",
"delaying": "Delaying", "delaying": "Delaying",
"finalizing": "Finalizing", "finalizing": "Finalizing",
"completed": "Completed",
"error": "Error", "error": "Error",
"disabled": "Disabled" "disabled": "Disabled"
} }
@@ -14,9 +14,11 @@
"motion_sensors": "Датчики движения", "motion_sensors": "Датчики движения",
"motion_filter": "Фильтр обнаружения движения (сек, 0 = без фильтрации)", "motion_filter": "Фильтр обнаружения движения (сек, 0 = без фильтрации)",
"off_delay": "Задержка после пропадания движения (сек, 0 = без задержки)", "off_delay": "Задержка после пропадания движения (сек, 0 = без задержки)",
"retention_days": "Хранить записи (дней, 0 = не удалять)",
"force_stop_sensor": "Датчик принудительной остановки (binary_sensor, switch, input_boolean)", "force_stop_sensor": "Датчик принудительной остановки (binary_sensor, switch, input_boolean)",
"force_stop_state": "Состояние для остановки", "force_stop_state": "Состояние для остановки",
"retention_days": "Хранить записи (дней, 0 = не удалять)",
"controlled_entities": "Управляемые устройства (light/switch)",
"control_states": "Включать при состояниях",
"log_level": "Уровень логирования" "log_level": "Уровень логирования"
}, },
"data_description": { "data_description": {
@@ -24,14 +26,16 @@
"camera_entity": "Выберите камеру из списка доступных", "camera_entity": "Выберите камеру из списка доступных",
"save_path": "Папка для сохранения записей относительно /media. Поддерживаются переменные, например camera_name", "save_path": "Папка для сохранения записей относительно /media. Поддерживаются переменные, например camera_name",
"filename_template": "Выберите формат даты и времени для имён файлов записей. Все форматы содержат секунды, чтобы избежать перезаписи файлов при нескольких срабатываниях в одну минуту", "filename_template": "Выберите формат даты и времени для имён файлов записей. Все форматы содержат секунды, чтобы избежать перезаписи файлов при нескольких срабатываниях в одну минуту",
"max_duration": "Максимальное время одной записии (10-3600 секунд). Если движение не прекратится, запись остановится по таймауту", "max_duration": "Максимальное время одной записи (10-3600 секунд). Если движение не прекратится, запись остановится по таймауту",
"prebuffer": "Сколько секунд до обнаружения движения включить в запись. Работает только если стрим уже активен", "prebuffer": "Сколько секунд до обнаружения движения включить в запись. Работает только если стрим уже активен",
"motion_sensors": "Можно выбрать несколько датчиков. Запись начнётся при сработке любого из них", "motion_sensors": "Можно выбрать несколько датчиков. Запись начнётся при сработке любого из них",
"motion_filter": "Задержка перед началом записи. Если движение пропадёт раньше — запись не начнётся (защита от ложных срабатываний)", "motion_filter": "Задержка перед началом записи. Если движение пропадёт раньше — запись не начнётся (защита от ложных срабатываний)",
"off_delay": "Сколько секунд ждать после пропадания движения перед остановкой записи. Если движение вернётся — таймер сбросится", "off_delay": "Сколько секунд ждать после пропадания движения перед остановкой записи. Если движение вернётся — таймер сбросится",
"retention_days": "Автоматически удалять записии старше указанного количества дней. 0 = не удалять никогда. Очистка выполняется ежедневно в 3:00",
"force_stop_sensor": "Необязательно. Сущность, при переходе в указанное состояние запись немедленно остановится", "force_stop_sensor": "Необязательно. Сущность, при переходе в указанное состояние запись немедленно остановится",
"force_stop_state": "Значение состояния датчика принудительной остановки (обычно 'off' или 'on')", "force_stop_state": "Значение состояния датчика принудительной остановки (обычно 'off' или 'on')",
"retention_days": "Автоматически удалять записи старше указанного количества дней. 0 = не удалять никогда. Очистка выполняется ежедневно в 3:00",
"controlled_entities": "Список светильников или переключателей, которые будут автоматически включаться/выключаться в зависимости от состояния интеграции",
"control_states": "Отметьте состояния, при которых управляемые устройства должны быть включены. В остальных состояниях они будут выключены (только если были включены интеграцией)",
"log_level": "Уровень детализации логов. DEBUG — для отладки, INFO — обычный режим" "log_level": "Уровень детализации логов. DEBUG — для отладки, INFO — обычный режим"
} }
} }
@@ -51,16 +55,20 @@
"prebuffer": "Предбуфер (сек, 0 = отключено)", "prebuffer": "Предбуфер (сек, 0 = отключено)",
"motion_filter": "Фильтр обнаружения движения (сек, 0 = без фильтрации)", "motion_filter": "Фильтр обнаружения движения (сек, 0 = без фильтрации)",
"off_delay": "Задержка после пропадания движения (сек, 0 = без задержки)", "off_delay": "Задержка после пропадания движения (сек, 0 = без задержки)",
"retention_days": "Хранить записии (дней, 0 = не удалять)", "retention_days": "Хранить записи (дней, 0 = не удалять)",
"controlled_entities": "Управляемые устройства (light/switch)",
"control_states": "Включать при состояниях",
"log_level": "Уровень логирования" "log_level": "Уровень логирования"
}, },
"data_description": { "data_description": {
"filename_template": "Выберите формат даты и времени для имён файлов записей", "filename_template": "Выберите формат даты и времени для имён файлов записей",
"max_duration": "Максимальное время одной записии (10-3600 секунд)", "max_duration": "Максимальное время одной записи (10-3600 секунд)",
"prebuffer": "Сколько секунд до обнаружения движения включить в запись", "prebuffer": "Сколько секунд до обнаружения движения включить в запись",
"motion_filter": "Задержка перед началом записии для защиты от ложных срабатываний", "motion_filter": "Задержка перед началом записи для защиты от ложных срабатываний",
"off_delay": "Сколько секунд ждать после пропадания движения перед остановкой записии", "off_delay": "Сколько секунд ждать после пропадания движения перед остановкой записи",
"retention_days": "Автоматически удалять записии старше указанного количества дней. 0 = не удалять никогда", "retention_days": "Автоматически удалять записи старше указанного количества дней. 0 = не удалять никогда",
"controlled_entities": "Список светильников или переключателей для автоматического управления",
"control_states": "Состояния, при которых устройства должны быть включены",
"log_level": "Уровень детализации логов" "log_level": "Уровень детализации логов"
} }
} }
@@ -76,7 +84,6 @@
"recording": "Запись", "recording": "Запись",
"delaying": "Задержка", "delaying": "Задержка",
"finalizing": "Завершение", "finalizing": "Завершение",
"completed": "Завершено",
"error": "Ошибка", "error": "Ошибка",
"disabled": "Отключено" "disabled": "Отключено"
} }
+32
View File
@@ -0,0 +1,32 @@
[tool.semantic_release]
tag_format = "v{version}"
version_variables = [
"custom_components/motion_recorder/manifest.json:version",
]
commit_message = "chore(release): v{version}"
# Отключаем создание релиза через API Gitea (релиз будет создаваться в GitHub)
upload_to_vcs_release = false
[tool.semantic_release.remote]
type = "gitea"
domain = "git.darkmode.duckdns.org"
api_domain = "git.darkmode.duckdns.org"
# Настройки для stable релизов (ветка main)
[tool.semantic_release.branches.main]
match = "(main|master)"
prerelease = false
# Настройки для beta релизов (ветка dev)
[tool.semantic_release.branches.dev]
match = "dev"
prerelease = true
prerelease_token = "beta"
[tool.semantic_release.commit_parser_options]
allowed_tags = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "revert"]
minor_tags = ["feat"]
patch_tags = ["fix", "perf"]