13 Commits
Author SHA1 Message Date
semantic-release 9fbb7cae18 chore(release): v1.1.3
Update Version / Update Version (push) Successful in 5s
2026-07-17 08:13:08 +00:00
sirmrak 0d64069548 fix: Test
Update Version / Update Version (push) Successful in 6s
2026-07-17 11:13:02 +03:00
sirmrak 03ea96b282 Обновить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 5s
2026-07-17 11:11:00 +03:00
sirmrak cac3274bbb Обновить pyproject.toml
Update Version / Update Version (push) Has been cancelled
2026-07-17 10:48:18 +03:00
semantic-release b6d87cb049 chore(release): v1.1.2
Update Version / Update Version (push) Successful in 5s
2026-07-11 15:33:27 +00:00
sirmrak dc85e69e28 fix: FST
Update Version / Update Version (push) Successful in 8s
При выключении switch запись полностью останавливается и не продолжается
При активации force_stop запись мгновенно останавливается и блокируется до деактивации
При деактивации force_stop система возвращается в нормальный режим
2026-07-11 18:33:15 +03:00
semantic-release 495fbe5dce chore(release): v1.1.1
Update Version / Update Version (push) Successful in 5s
2026-07-10 09:25:11 +00:00
sirmrak 6ccca63270 fix: Оформление
Update Version / Update Version (push) Successful in 7s
2026-07-10 12:25:05 +03:00
sirmrak f9cbcfe7f6 Добавить .github/workflows/release.yml
Update Version / Update Version (push) Successful in 5s
2026-07-10 12:14:02 +03:00
sirmrak cdba65f5f7 Добавить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 5s
2026-07-10 12:12:55 +03:00
sirmrak c817a47e30 Добавить pyproject.toml 2026-07-10 12:11:38 +03:00
sirmrak 96ff3d4285 Обновить README.md 2026-07-08 13:29:45 +03:00
sirmrak 457ff1fa02 Обновить README.ru.md 2026-07-08 13:29:21 +03:00
9 changed files with 343 additions and 19 deletions
+46
View File
@@ -0,0 +1,46 @@
name: Update Version
on:
push:
branches:
- main
- master
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
+132
View File
@@ -0,0 +1,132 @@
name: Test and Release
on:
push:
tags:
- 'v*'
workflow_dispatch: # Позволяет запускать вручную
jobs:
# Шаг 1: Валидация структуры HA интеграции
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"
# Шаг 2: Запуск тестов (если есть)
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
# Шаг 3: Проверка стиля кода
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"
# Шаг 4: Создание релиза (только если все проверки пройдены)
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
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 }}"
echo "🚀 Создаём релиз $TAG в GitHub..."
cat > /tmp/release_notes.md << EOF
## Изменения в версии $TAG
$COMMITS
EOF
gh release create "$TAG" \
--title "Release $TAG" \
--notes-file /tmp/release_notes.md \
--target main
echo "✅ Релиз создан!"
+34
View File
@@ -0,0 +1,34 @@
# CHANGELOG
<!-- version list -->
## v1.1.3 (2026-07-17)
### Bug Fixes
- Test
([`0d64069`](https://git.darkmode.duckdns.org/sirmrak/motion_recorder/commit/0d64069548d980814ae3403c2b6b43ac4491b918))
## v1.1.2 (2026-07-11)
### Bug Fixes
- FST
([`dc85e69`](https://192.168.200.208:3000/sirmrak/motion_recorder/commit/dc85e69e289cb2f13ddb0e51a98bfc207c73a9d6))
## v1.1.1 (2026-07-10)
### Bug Fixes
- Оформление
([`6ccca63`](https://192.168.200.208:3000/sirmrak/motion_recorder/commit/6ccca6327021b53a8cd46e62bb0d740f0a609043))
## v1.1.0 (2026-07-05)
## v1.0.0 (2026-07-03)
- Initial Release
+1 -1
View File
@@ -1,4 +1,4 @@
# Motion Recorder for Home Assistant # <img src="custom_components/motion_recorder/brand/icon.png" width="64"> </img> Motion Recorder for Home Assistant
English | [Русский](README.ru.md) English | [Русский](README.ru.md)
## A powerful Home Assistant integration for recording camera streams based on motion detection with advanced filtering and automated management. ## A powerful Home Assistant integration for recording camera streams based on motion detection with advanced filtering and automated management.
+1 -1
View File
@@ -1,4 +1,4 @@
# Motion Recorder для Home Assistant # <img src="custom_components/motion_recorder/brand/icon.png" width="64"> </img> Motion Recorder для Home Assistant
[English](README.md) | Русский [English](README.md) | Русский
Мощная интеграция Home Assistant для записи камер по датчику движения с продвинутой фильтрацией и автоматическим управлением. Мощная интеграция Home Assistant для записи камер по датчику движения с продвинутой фильтрацией и автоматическим управлением.
@@ -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
@@ -91,6 +91,8 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
# Enabled state # Enabled state
self._enabled = True self._enabled = True
self._force_stopped = False
# Listeners # Listeners
self._unsub_motion_listeners = [] self._unsub_motion_listeners = []
@@ -146,6 +148,7 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
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
@@ -159,17 +162,39 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.info("Recording %s", "enabled" if enabled else "disabled") _LOGGER.info("Recording %s", "enabled" if enabled else "disabled")
if not enabled: if not enabled:
await self._cancel_all_timers()
# Останавливаем запись если идёт
if self._state == STATE_RECORDING: if self._state == STATE_RECORDING:
await self._stop_recording() await self._stop_recording()
# Переходим в disabled
await self._update_state(STATE_DISABLED) await self._update_state(STATE_DISABLED)
else: else:
await self._update_state(STATE_IDLE) # Включаем только если не заблокированы force_stop
if self._force_stopped:
_LOGGER.info("Cannot enable: force stop is active")
await self._update_state(STATE_DISABLED)
else:
await self._update_state(STATE_IDLE)
self.async_set_updated_data({ self.async_set_updated_data({
"state": self._state, "state": self._state,
"attributes": self.attributes "attributes": self.attributes
}) })
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.""" """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)
@@ -364,9 +389,43 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
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
_LOGGER.info("Force stop blocking activated")
# Отменяем все таймеры
await self._cancel_all_timers()
# Останавливаем запись если идёт
if self._state == STATE_RECORDING:
await self._stop_recording()
# Переходим в disabled
await self._update_state(STATE_DISABLED)
async def _deactivate_force_stop(self) -> None:
"""Деактивировать force stop блокировку."""
self._force_stopped = False
_LOGGER.info("Force stop blocking deactivated")
# Возвращаемся в idle только если запись включена
if self._enabled:
await self._update_state(STATE_IDLE)
else:
await self._update_state(STATE_DISABLED)
def _is_any_motion_active(self) -> bool: def _is_any_motion_active(self) -> bool:
"""Check if any motion sensor is currently in 'on' state.""" """Check if any motion sensor is currently in 'on' state."""
@@ -381,6 +440,10 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
if not self._enabled: if not self._enabled:
_LOGGER.debug("Recording disabled, ignoring motion") _LOGGER.debug("Recording disabled, ignoring motion")
return return
if self._force_stopped:
_LOGGER.debug("Force stop active, ignoring motion")
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)
@@ -423,6 +486,12 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
async def _start_recording_after_filter(self, _=None): async def _start_recording_after_filter(self, _=None):
"""Start recording after motion filter.""" """Start recording after motion filter."""
if not self._enabled or self._force_stopped:
_LOGGER.debug("Cannot start recording: enabled=%s, force_stopped=%s",
self._enabled, self._force_stopped)
await self._update_state(STATE_IDLE if self._enabled else STATE_DISABLED)
return
_LOGGER.debug("Starting recording after filter") _LOGGER.debug("Starting recording after filter")
self._motion_detect_timer = None self._motion_detect_timer = None
triggered_by = self._attributes.get("triggered_by") triggered_by = self._attributes.get("triggered_by")
@@ -430,6 +499,10 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
async def _handle_motion_stopped(self): async def _handle_motion_stopped(self):
"""Handle motion stopped with off-delay.""" """Handle motion stopped with off-delay."""
if self._force_stopped:
_LOGGER.debug("Force stop active, ignoring motion stopped")
return
_LOGGER.debug("Motion stopped, current state: %s", self._state) _LOGGER.debug("Motion stopped, current state: %s", self._state)
if self._is_any_motion_active(): if self._is_any_motion_active():
@@ -465,10 +538,19 @@ 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)
if not self._enabled:
await self._update_state(STATE_DISABLED)
else:
await self._update_state(STATE_IDLE)
async def _stop_recording_after_delay(self, _=None): async def _stop_recording_after_delay(self, _=None):
"""Stop recording after off-delay.""" """Stop recording after off-delay."""
if self._force_stopped or not self._enabled:
_LOGGER.debug("Cannot stop recording: enabled=%s, force_stopped=%s",
self._enabled, self._force_stopped)
return
_LOGGER.debug("Off-delay timer expired") _LOGGER.debug("Off-delay timer expired")
self._off_delay_timer = None self._off_delay_timer = None
@@ -482,6 +564,12 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
async def _start_recording(self, triggered_by): async def _start_recording(self, triggered_by):
"""Start recording the camera.""" """Start recording the camera."""
if not self._enabled or self._force_stopped:
_LOGGER.warning("Cannot start recording: enabled=%s, force_stopped=%s",
self._enabled, self._force_stopped)
await self._update_state(STATE_IDLE if self._enabled else STATE_DISABLED)
return
try: try:
if self._current_stream: if self._current_stream:
recorder_output = self._current_stream.outputs().get(_RECORDER_PROVIDER) recorder_output = self._current_stream.outputs().get(_RECORDER_PROVIDER)
@@ -578,13 +666,13 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
_LOGGER.error("Error stopping recording: %s", err, exc_info=True) _LOGGER.error("Error stopping recording: %s", err, exc_info=True)
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(5)
if not self._enabled: if not self._enabled or self._force_stopped:
await self._update_state(STATE_DISABLED) await self._update_state(STATE_DISABLED)
else: else:
await self._update_state(STATE_IDLE) await self._update_state(STATE_IDLE)
async def _force_stop_recording(self): async def _force_stop_recording(self):
"""Force stop recording.""" """Force stop recording (вызывается при активации force_stop)."""
_LOGGER.info("Force stop recording called") _LOGGER.info("Force stop recording called")
await self._stop_recording() await self._stop_recording()
@@ -628,9 +716,8 @@ class MotionRecorderCoordinator(DataUpdateCoordinator):
self._recording_start_time = None self._recording_start_time = None
self._current_filename = None self._current_filename = None
# Проверяем, не был ли выключен переключатель во время записи if not self._enabled or self._force_stopped:
if not self._enabled: _LOGGER.debug("Recording disabled or force stopped during finalization, transitioning to disabled")
_LOGGER.debug("Recording disabled during finalization, transitioning to disabled")
await self._update_state(STATE_DISABLED) await self._update_state(STATE_DISABLED)
else: else:
_LOGGER.debug("Transitioning to idle") _LOGGER.debug("Transitioning to idle")
@@ -1,12 +1,12 @@
{ {
"domain": "motion_recorder", "domain": "motion_recorder",
"name": "Motion Recorder", "name": "Motion Recorder",
"version": "1.1.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"
} }
+25
View File
@@ -0,0 +1,25 @@
[tool.semantic_release]
tag_format = "v{version}"
version_variables = [
"custom_components/motion_recorder/manifest.json:version",
]
commit_message = "chore(release): v{version}"
# ВАЖНО: не создаём релиз в Gitea (релиз будет в GitHub)
upload_to_vcs_release = false
[tool.semantic_release.remote]
type = "gitea"
domain = "git.darkmode.duckdns.org"
api_domain = "git.darkmode.duckdns.org"
[tool.semantic_release.branches.main]
match = "(main|master)"
prerelease = false
[tool.semantic_release.commit_parser_options]
allowed_tags = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "revert"]
minor_tags = ["feat"]
patch_tags = ["fix", "perf"]