Compare commits
30
Commits
v1.0.0
...
81f176ef2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81f176ef2b | ||
|
|
5016165c10 | ||
|
|
5e3e149c37 | ||
|
|
e56f297f6d | ||
|
|
0860d19813 | ||
|
|
9fbb7cae18 | ||
|
|
0d64069548 | ||
|
|
03ea96b282 | ||
|
|
cac3274bbb | ||
|
|
b6d87cb049 | ||
|
|
dc85e69e28 | ||
|
|
495fbe5dce | ||
|
|
6ccca63270 | ||
|
|
f9cbcfe7f6 | ||
|
|
cdba65f5f7 | ||
|
|
c817a47e30 | ||
|
|
96ff3d4285 | ||
|
|
457ff1fa02 | ||
|
|
33c170ca15 | ||
|
|
2c473b157c | ||
|
|
a1b63d2ee1 | ||
|
|
92ecd51e2e | ||
|
|
e60a47ef48 | ||
|
|
ab3de76de5 | ||
|
|
84127a1229 | ||
|
|
57d2441d3b | ||
|
|
ea1b6e3916 | ||
|
|
792a185204 | ||
|
|
015e3221df | ||
|
|
02cb16325c |
@@ -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
|
||||||
@@ -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 "✅ Релиз создан!"
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Отключено"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user