Compare commits

..
86 Commits
Author SHA1 Message Date
semantic-release 759f7c4a95 chore(release): v1.2.0-beta.2
Update Version / Update Version (push) Successful in 5s
2026-07-22 06:33:58 +00:00
sirmrak 569f49a470 fix: Dev update
Update Version / Update Version (push) Successful in 6s
2026-07-22 09:33:51 +03:00
semantic-release 584d7e6669 chore(release): v1.2.0-beta.1
Update Version / Update Version (push) Successful in 5s
2026-07-22 06:30:58 +00:00
sirmrak 3d5fd50c95 feat: major FSM improvements, optimization and bug fixes
Update Version / Update Version (push) Successful in 6s
- Fix manual override detection using context prefix matching
- Add force stop sensor with STATE_FORCE_STOPPED mode
- Implement smart manual mode (turn on remaining lights on motion)
- Fix lux_cooldown activation on all light turn-offs
- Fix extend sensor re-triggering in STATE_ACTIVE
- Add conditional lux entities creation
- Allow manual_idle_timeout = 0 to disable timer
- Optimize code with universal timer and sensor methods
- Add integration name prefix to logs
- Support light domain in addition to switch
2026-07-22 09:30:51 +03:00
sirmrak 283a16082b Merge pull request 'Main' (#2) from main into dev
Update Version / Update Version (push) Successful in 6s
Reviewed-on: #2
2026-07-22 09:16:42 +03:00
semantic-release cfe316cca1 chore(release): v1.1.0
Update Version / Update Version (push) Successful in 5s
2026-07-22 06:14:11 +00:00
sirmrak fcc6073298 Merge pull request 'Dev' (#1) from dev into main
Update Version / Update Version (push) Successful in 7s
Reviewed-on: #1
2026-07-22 09:14:01 +03:00
sirmrak 489a868a4d ci: support beta releases in GitHub workflow
Update Version / Update Version (push) Successful in 6s
2026-07-22 09:06:46 +03:00
semantic-release 7afe64d14c chore(release): v1.1.0-beta.1
Update Version / Update Version (push) Successful in 5s
2026-07-22 06:05:53 +00:00
sirmrak c366bc457a ci: add dev branch support for beta releases
Update Version / Update Version (push) Successful in 9s
2026-07-22 09:05:42 +03:00
sirmrak fa4d133d50 feat: add dev branch for beta releases 2026-07-22 09:04:38 +03:00
semantic-release f129191a48 chore(release): v1.0.17
Update Version / Update Version (push) Successful in 5s
2026-07-17 08:07:41 +00:00
sirmrak 31237e76d9 fix: Test
Update Version / Update Version (push) Successful in 6s
2026-07-17 10:58:44 +03:00
sirmrak fc59a786ed Обновить pyproject.toml
Update Version / Update Version (push) Successful in 5s
2026-07-17 10:57:53 +03:00
sirmrak 71cbd70f9e Обновить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 8s
2026-07-17 10:57:16 +03:00
semantic-release f12371e519 chore(release): v1.0.16
Update Version / Update Version (push) Successful in 5s
2026-07-10 09:09:57 +00:00
sirmrak eae1c9cef2 fix: Test new secrets
Update Version / Update Version (push) Successful in 6s
2026-07-10 12:09:50 +03:00
semantic-release 8dff505bb4 chore(release): v1.0.15
Update Version / Update Version (push) Successful in 5s
2026-07-10 06:49:05 +00:00
sirmrak 342a98327d fix: lux_cooldown_active
Update Version / Update Version (push) Successful in 7s
2026-07-10 09:48:56 +03:00
sirmrak d10e83c750 Добавить LICENSE
Update Version / Update Version (push) Successful in 5s
2026-07-09 22:28:37 +03:00
semantic-release 0deb61f920 chore(release): v1.0.14
Update Version / Update Version (push) Successful in 5s
2026-07-09 19:22:51 +00:00
sirmrak 563095cbcf fix: Удалены переводы атрибутов из custom_components/motion_light/translations
Update Version / Update Version (push) Successful in 6s
2026-07-09 22:22:43 +03:00
sirmrak 952feac482 fixЖ перевод атрибутов удален
Update Version / Update Version (push) Successful in 5s
2026-07-09 22:20:23 +03:00
sirmrak 7c229b37f2 Обновить custom_components/motion_light/translations/ru.json
Update Version / Update Version (push) Successful in 5s
2026-07-09 22:11:42 +03:00
sirmrak 19546a12e6 Обновить custom_components/motion_light/translations/en.json
Update Version / Update Version (push) Successful in 5s
2026-07-09 22:09:15 +03:00
sirmrak 7b9b1030f2 Обновить custom_components/motion_light/translations/en.json
Update Version / Update Version (push) Successful in 5s
2026-07-09 22:08:45 +03:00
sirmrak 13df970b21 Обновить custom_components/motion_light/manifest.json
Update Version / Update Version (push) Successful in 5s
2026-07-09 22:05:41 +03:00
semantic-release 8069a5d635 chore(release): v1.0.13
Update Version / Update Version (push) Successful in 5s
2026-07-09 19:00:28 +00:00
sirmrak 356b61e6e6 fix: Test validation
Update Version / Update Version (push) Successful in 6s
2026-07-09 22:00:19 +03:00
sirmrak 3b93a6a16b Добавление тестов
Update Version / Update Version (push) Successful in 5s
2026-07-09 21:59:37 +03:00
semantic-release aa7eb0a9d9 chore(release): v1.0.12
Update Version / Update Version (push) Successful in 5s
2026-07-09 18:47:22 +00:00
sirmrak 0888c63b80 fix: test
Update Version / Update Version (push) Successful in 5s
2026-07-09 21:47:14 +03:00
sirmrak c6e7b332cf Обновить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 5s
2026-07-09 21:45:52 +03:00
semantic-release 7b4fdfe629 chore(release): v1.0.11
Update Version / Update Version (push) Successful in 5s
2026-07-09 18:35:55 +00:00
sirmrak 15dd7e3880 fix: Test
Update Version / Update Version (push) Failing after 6s
2026-07-09 21:35:49 +03:00
sirmrak 28811370e9 Обновить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 5s
2026-07-09 21:35:26 +03:00
sirmrak a1fca2f4d1 Обновить pyproject.toml
Update Version / Update Version (push) Successful in 6s
2026-07-09 21:34:44 +03:00
semantic-release d479bdb583 chore(release): v1.0.10
Update Version / Update Version (push) Successful in 6s
2026-07-09 18:26:23 +00:00
sirmrak 010a1144c5 fix: Test
Update Version / Update Version (push) Failing after 7s
2026-07-09 21:26:14 +03:00
sirmrak 6cf92d83f0 Обновить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 5s
2026-07-09 21:24:56 +03:00
sirmrak 9f7199beab Обновить pyproject.toml
Update Version / Update Version (push) Successful in 5s
2026-07-09 21:23:53 +03:00
semantic-release a02ae4106b chore(release): v1.0.9
Update Version / Update Version (push) Successful in 4s
2026-07-09 18:08:02 +00:00
sirmrak 6a411cea91 fix: test
Update Version / Update Version (push) Failing after 7s
2026-07-09 21:07:53 +03:00
sirmrak 4ff72fdb07 Обновить pyproject.toml
Update Version / Update Version (push) Successful in 5s
2026-07-09 21:07:03 +03:00
semantic-release 93d1afd7e0 chore(release): v1.0.8
Update Version / Update Version (push) Successful in 5s
2026-07-09 17:49:53 +00:00
sirmrak 11bf31885e fix: Тестовый релиз
Update Version / Update Version (push) Failing after 6s
2026-07-09 20:49:43 +03:00
sirmrak 8b93ed6187 Удалить test.text
Update Version / Update Version (push) Successful in 5s
2026-07-09 20:48:31 +03:00
sirmrak 286f84fbb0 Добавить test.text
Update Version / Update Version (push) Successful in 5s
2026-07-09 20:47:26 +03:00
sirmrak 01a504f0ba Добавить .github/workflows/release.yml
Update Version / Update Version (push) Successful in 5s
2026-07-09 20:44:49 +03:00
semantic-release c97fc433bc chore(release): v1.0.7
Update Version / Update Version (push) Successful in 5s
2026-07-09 17:38:46 +00:00
sirmrak c5a78f998f fix: Тестим авторелиз
Update Version / Update Version (push) Failing after 6s
2026-07-09 20:38:37 +03:00
semantic-release eb106d7fe4 chore(release): v1.0.6
Update Version / Update Version (push) Successful in 5s
2026-07-09 17:15:57 +00:00
sirmrak 4ffa66c312 Удалить CHANGELOG.md
Update Version / Update Version (push) Failing after 6s
2026-07-09 20:15:51 +03:00
sirmrak 3ff545c6df Обновить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 5s
2026-07-09 20:14:48 +03:00
sirmrak 458603e80c Обновить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Failing after 5s
2026-07-09 20:05:55 +03:00
semantic-release 81dbc70b04 chore(release): v1.0.6
Update Version / Update Version (push) Successful in 6s
2026-07-09 16:56:33 +00:00
sirmrak 58ae700e0b fix: тестовое исправление для автоматического релиза
Update Version / Update Version (push) Successful in 6s
2026-07-09 19:56:24 +03:00
sirmrak 399f55c57b Обновить .gitea/workflows/update_version.yml
Update Version / Update Version (push) Successful in 6s
2026-07-09 19:55:24 +03:00
semantic-release 900c40c8a1 chore(release): v1.0.5
/ Update Version (push) Successful in 5s
2026-07-09 16:43:26 +00:00
sirmrak 860584ca79 Добавить .gitea/workflows/update_version.yml
/ Update Version (push) Successful in 7s
2026-07-09 19:43:19 +03:00
sirmrak d230ec6590 Удалить .gitea/workflows/release.yml 2026-07-09 19:40:38 +03:00
sirmrak e0868fae9b fix: Test
HA Integration Release / Validate HA Integration (push) Successful in 2s
HA Integration Release / Semantic Release (push) Failing after 41s
2026-07-09 14:09:49 +03:00
sirmrak 59d3f9accd fix: Release 2026-07-09 14:06:50 +03:00
semantic-release ee6dde688c chore(release): v1.0.7
HA Integration Release / Validate HA Integration (push) Successful in 2s
HA Integration Release / Semantic Release (push) Successful in 41s
2026-07-09 11:01:55 +00:00
sirmrak e10e3e7c19 fix: Run Semantic Release
HA Integration Release / Validate HA Integration (push) Successful in 2s
HA Integration Release / Semantic Release (push) Failing after 43s
2026-07-09 14:01:05 +03:00
semantic-release 33756f1659 chore(release): v1.0.6
HA Integration Release / Validate HA Integration (push) Successful in 3s
HA Integration Release / Semantic Release (push) Successful in 41s
2026-07-09 10:58:00 +00:00
sirmrak bd34c9fd81 fix: release.yaml
HA Integration Release / Validate HA Integration (push) Successful in 3s
HA Integration Release / Semantic Release (push) Failing after 43s
2026-07-09 13:57:12 +03:00
semantic-release 590e110efa chore(release): v1.0.5
HA Integration Release / Validate HA Integration (push) Successful in 2s
HA Integration Release / Semantic Release (push) Successful in 5s
2026-07-09 10:50:44 +00:00
sirmrak 92b9392149 fix: test commit for release
HA Integration Release / Validate HA Integration (push) Successful in 3s
HA Integration Release / Semantic Release (push) Failing after 6s
2026-07-09 13:50:30 +03:00
semantic-release fced8328d7 chore(release): v1.0.6
HA Integration Release / Validate HA Integration (push) Successful in 2s
HA Integration Release / Semantic Release (push) Successful in 5s
2026-07-09 10:43:47 +00:00
sirmrak ae301f25db fix: disable SSL verification for semantic-release
HA Integration Release / Validate HA Integration (push) Successful in 2s
HA Integration Release / Semantic Release (push) Failing after 6s
2026-07-09 13:43:33 +03:00
semantic-release fcc4de59c1 chore(release): v1.0.5
HA Integration Release / Validate HA Integration (push) Successful in 2s
HA Integration Release / Semantic Release (push) Successful in 5s
2026-07-09 10:40:40 +00:00
sirmrak 4587148d70 fix: configure semantic-release for Gitea with SSL
HA Integration Release / Validate HA Integration (push) Successful in 2s
HA Integration Release / Semantic Release (push) Failing after 5s
2026-07-09 13:40:26 +03:00
semantic-release 6c73814724 1.0.0
HA Integration Release / Validate HA Integration (push) Successful in 2s
HA Integration Release / Semantic Release (push) Successful in 6s
Automatically generated by python-semantic-release
2026-07-09 10:32:03 +00:00
sirmrak 8c4009dc82 Обновить .gitea/workflows/release.yml
HA Integration Release / Validate HA Integration (push) Successful in 3s
HA Integration Release / Semantic Release (push) Failing after 15s
2026-07-09 13:31:39 +03:00
sirmrak 37de17749e Обновить .gitea/workflows/release.yml
HA Integration Release / Validate HA Integration (push) Failing after 3s
HA Integration Release / Semantic Release (push) Has been skipped
2026-07-09 13:28:52 +03:00
sirmrak d4c2548498 fix: Изменил размер картинки
Test Runner / Test Job (push) Successful in 4s
HA Integration Release / Validate HA Integration (push) Failing after 4s
HA Integration Release / Semantic Release (push) Has been skipped
2026-07-09 13:17:44 +03:00
sirmrak f889f18cd9 Добавить .gitea/workflows/release.yml
HA Integration Release / Validate HA Integration (push) Failing after 16s
HA Integration Release / Semantic Release (push) Has been skipped
Test Runner / Test Job (push) Successful in 7s
2026-07-09 13:16:33 +03:00
sirmrak 9abb91c3f9 Добавить pyproject.toml
Test Runner / Test Job (push) Successful in 4s
2026-07-09 13:15:26 +03:00
sirmrak 76388df08c GIT_SSL_NO_VERIFY: true
Test Runner / Test Job (push) Successful in 18s
2026-07-09 13:07:24 +03:00
sirmrak 9626fafd2f Добавить .gitea/workflows/test.yml
Test Runner / Test Job (push) Failing after 55s
2026-07-09 12:52:39 +03:00
sirmrak 71eb3a7c86 Обновить custom_components/motion_light/manifest.json 2026-07-09 09:56:10 +03:00
sirmrak b9dd8bcad7 Обновить custom_components/motion_light/translations/en.json 2026-07-09 09:48:46 +03:00
sirmrak b1a5eca9a5 Обновить custom_components/motion_light/translations/ru.json 2026-07-09 09:47:31 +03:00
sirmrak 182a46cf92 Обновить custom_components/motion_light/manifest.json 2026-07-09 09:22:02 +03:00
sirmrak e5c10104b8 Обновить custom_components/motion_light/coordinator.py 2026-07-09 09:19:53 +03:00
12 changed files with 771 additions and 642 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Test Runner
on:
push:
branches:
- main
- master
env:
GIT_SSL_NO_VERIFY: true
jobs:
test:
name: Test Job
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Echo message
run: |
echo "Hello from Gitea Actions!"
echo "Runner is working correctly!"
date
pwd
ls -la
- name: Test Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check Python version
run: python --version
+46
View File
@@ -0,0 +1,46 @@
name: Update Version
on:
push:
branches:
- main
- master
- dev
jobs:
update-version:
name: Update Version
runs-on: ubuntu-latest
# Пропускаем запуск, если коммит сделал сам бот (избегаем бесконечного цикла)
if: github.actor != 'gitea-actions[bot]' && github.actor != 'Gitea Actions Bot'
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install python-semantic-release
- name: Configure Git
run: |
git config --local user.email "actions@gitea.local"
git config --local user.name "Gitea Actions Bot"
- name: Run semantic-release
env:
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
run: |
unset GITHUB_SERVER_URL
unset GITHUB_API_URL
unset GITHUB_REPOSITORY
semantic-release version
+145
View File
@@ -0,0 +1,145 @@
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..."
if [ "$IS_BETA" = "true" ]; then
TITLE="Beta Release $TAG"
else
TITLE="Release $TAG"
fi
cat > /tmp/release_notes.md << EOF
## $TITLE
$COMMITS
EOF
gh release create "$TAG" \
--title "$TITLE" \
--notes-file /tmp/release_notes.md \
--target main \
${IS_BETA:+--prerelease}
echo "✅ Релиз создан!"
+171
View File
@@ -0,0 +1,171 @@
# CHANGELOG
<!-- version list -->
## v1.2.0-beta.2 (2026-07-22)
### Bug Fixes
- Dev update
([`569f49a`](https://git.darkmode.duckdns.org/sirmrak/motion_light/commit/569f49a47072fdb9a7797d16e6670865360c71c1))
## v1.2.0-beta.1 (2026-07-22)
### Features
- Major FSM improvements, optimization and bug fixes
([`3d5fd50`](https://git.darkmode.duckdns.org/sirmrak/motion_light/commit/3d5fd50c951c3e255e3dc73ea9f1cec87da879fe))
## v1.1.0 (2026-07-22)
## v1.1.0-beta.1 (2026-07-22)
### Features
- Add dev branch for beta releases
([`fa4d133`](https://git.darkmode.duckdns.org/sirmrak/motion_light/commit/fa4d133d50e896428883919c98ef830780aacce2))
## v1.0.17 (2026-07-17)
### Bug Fixes
- Test
([`31237e7`](https://git.darkmode.duckdns.org/sirmrak/motion_light/commit/31237e76d9c770dae4e04fad6d7a0196340a60c5))
## v1.0.16 (2026-07-10)
### Bug Fixes
- Test new secrets
([`eae1c9c`](https://192.168.200.208:3000/sirmrak/motion_light/commit/eae1c9cef21aa9285a6e5b2174949b6bf2942154))
## v1.0.15 (2026-07-10)
### Bug Fixes
- Lux_cooldown_active
([`342a983`](https://192.168.200.208:3000/sirmrak/motion_light/commit/342a98327d2297977323e12f84e0dc9bc8b3a49d))
## v1.0.14 (2026-07-09)
### Bug Fixes
- Удалены переводы атрибутов из custom_components/motion_light/translations
([`563095c`](https://192.168.200.208:3000/sirmrak/motion_light/commit/563095cbcf77d22d02a7176faf5aec6837d18b37))
## v1.0.13 (2026-07-09)
### Bug Fixes
- Test validation
([`356b61e`](https://192.168.200.208:3000/sirmrak/motion_light/commit/356b61e6e618a423503d44d2f0d9ef96fee0da0d))
## v1.0.12 (2026-07-09)
### Bug Fixes
- Test
([`0888c63`](https://192.168.200.208:3000/sirmrak/motion_light/commit/0888c63b804be1e5bedd7e72fbfd142dd4f88287))
## v1.0.11 (2026-07-09)
### Bug Fixes
- Test
([`15dd7e3`](https://192.168.200.208:3000/sirmrak/motion_light/commit/15dd7e3880dad782f502eb70db6aa8ca23f803bc))
## v1.0.10 (2026-07-09)
### Bug Fixes
- Test
([`010a114`](https://192.168.200.208:3000/sirmrak/motion_light/commit/010a1144c5dc5252831126bfc9592036a7ca2518))
## v1.0.9 (2026-07-09)
### Bug Fixes
- Test
([`6a411ce`](https://192.168.200.208:3000/sirmrak/motion_light/commit/6a411cea91047e9714519dbb190d5d92252b024a))
## v1.0.8 (2026-07-09)
### Bug Fixes
- Тестовый релиз
([`11bf318`](https://192.168.200.208:3000/sirmrak/motion_light/commit/11bf31885eb113d02cf5c1558446a4671c1dc202))
## v1.0.7 (2026-07-09)
### Bug Fixes
- Тестим авторелиз
([`c5a78f9`](https://192.168.200.208:3000/sirmrak/motion_light/commit/c5a78f998fda972f0dfe4f938f0855d3c1d28aee))
## v1.0.6 (2026-07-09)
### Bug Fixes
- Тестовое исправление для автоматического релиза
([`58ae700`](https://192.168.200.208:3000/sirmrak/motion_light/commit/58ae700e0ba31b664222eccd96aaa17d30d2eff4))
## v1.0.5 (2026-07-09)
### Bug Fixes
- Configure semantic-release for Gitea with SSL
([`4587148`](https://192.168.200.208:3000/sirmrak/motion_light/commit/4587148d70e362fdd2a47523df0da959499fba2f))
- Disable SSL verification for semantic-release
([`ae301f2`](https://192.168.200.208:3000/sirmrak/motion_light/commit/ae301f25db468e56f9378582215b4dac6979722e))
- Release
([`59d3f9a`](https://192.168.200.208:3000/sirmrak/motion_light/commit/59d3f9accd5d2cb3b8e1ef4f403c7445e7da7b46))
- Release.yaml
([`bd34c9f`](https://192.168.200.208:3000/sirmrak/motion_light/commit/bd34c9fd81002974c08a6f349f0234b4e78353aa))
- Run Semantic Release
([`e10e3e7`](https://192.168.200.208:3000/sirmrak/motion_light/commit/e10e3e7c19c2da63289cd499cca5a5eacee22157))
- Test
([`e0868fa`](https://192.168.200.208:3000/sirmrak/motion_light/commit/e0868fae9ba86cd76eb070c5057960741eecba1e))
- Test commit for release
([`92b9392`](https://192.168.200.208:3000/sirmrak/motion_light/commit/92b93921498c7afd21d1dceec51518894c1efdcd))
- Изменил размер картинки
([`d4c2548`](https://192.168.200.208:3000/sirmrak/motion_light/commit/d4c25484981a33ab4455027ffb4ef693256aa414))
## v1.0.4 (2026-07-09)
## v1.0.3 (2026-07-08)
## v1.0.2 (2026-07-08)
## v1.0.1 (2026-07-08)
## v1.0.0 (2026-07-08)
- Initial Release
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sirmrak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1,5 +1,5 @@
[English](README.md) | Русский
# <img src="custom_components/motion_light/brand/icon.png" width="64"> </img> Motion Light
# <img src="custom_components/motion_light/brand/icon.png" width="32"> </img> Motion Light
Умная интеграция для Home Assistant на основе конечного автомата (FSM) для управления освещением по датчикам движения, освещенности и ручным переключениям.
+1 -1
View File
@@ -1,4 +1,4 @@
"""The Motion Light integration."""
"""The Motion Light integration. Dev"""
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+153 -472
View File
@@ -35,15 +35,18 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
class PrefixLoggerAdapter(logging.LoggerAdapter):
"""Logger adapter that adds a prefix to log messages."""
def process(self, msg, kwargs):
return f"[{self.extra.get('name', 'Unknown')}] {msg}", kwargs
def _get_config_value(entry, key, default=None):
"""Get config value from options first, then from data, then default."""
return entry.options.get(key, entry.data.get(key, default))
class MotionLightCoordinator(DataUpdateCoordinator):
"""Motion Light coordinator."""
@@ -52,14 +55,9 @@ class MotionLightCoordinator(DataUpdateCoordinator):
self.entry = entry
self.logger = PrefixLoggerAdapter(_LOGGER, {"name": entry.title})
super().__init__(
hass,
self.logger,
name=f"Motion Light {entry.title}",
)
super().__init__(hass, self.logger, name=f"Motion Light {entry.title}")
self.hass = hass
self.switch_entities = _get_config_value(entry, CONF_SWITCH_ENTITIES, [])
self.main_sensors = _get_config_value(entry, CONF_MAIN_SENSORS, [])
self.extend_sensors = _get_config_value(entry, CONF_EXTEND_SENSORS, [])
@@ -95,39 +93,37 @@ class MotionLightCoordinator(DataUpdateCoordinator):
self._manual_idle_timer = None
self._manual_off_cooldown_timer = None
# Context & Flags
# Flags
self._lux_cooldown_active = False
@property
def state(self):
"""Return current state."""
return self._state
@property
def attributes(self):
"""Return current attributes."""
attrs = self._attributes.copy()
attrs["switch_entities"] = self.switch_entities
attrs["main_sensors"] = self.main_sensors
attrs["extend_sensors"] = self.extend_sensors
attrs["lux_sensor"] = self.lux_sensor
attrs["force_stop_sensor"] = self.force_stop_sensor
attrs["force_stop_state"] = self.force_stop_state
attrs["force_stop_active"] = self._state == STATE_FORCE_STOPPED
attrs["active_main_sensors"] = self._get_active_main_sensors()
attrs["active_extend_sensors"] = self._get_active_extend_sensors()
attrs["lux_value"] = self._get_current_lux()
attrs["lux_cooldown_active"] = self._lux_cooldown_active
attrs["lux_check_active"] = self._lux_check_timer is not None
attrs.update({
"switch_entities": self.switch_entities,
"main_sensors": self.main_sensors,
"extend_sensors": self.extend_sensors,
"lux_sensor": self.lux_sensor,
"force_stop_sensor": self.force_stop_sensor,
"force_stop_state": self.force_stop_state,
"force_stop_active": self._state == STATE_FORCE_STOPPED,
"active_main_sensors": self._get_active_sensors(self.main_sensors),
"active_extend_sensors": self._get_active_sensors(self.extend_sensors),
"lux_value": self._get_current_lux(),
"lux_cooldown_active": self._lux_cooldown_active,
"lux_check_active": self._lux_check_timer is not None,
})
return attrs
@property
def is_enabled(self) -> bool:
"""Return if integration is enabled."""
return self._enabled
async def set_enabled(self, enabled: bool) -> None:
"""Set enabled state."""
self._enabled = enabled
self.logger.info("Motion Light %s", "enabled" if enabled else "disabled")
if not enabled:
@@ -135,8 +131,7 @@ class MotionLightCoordinator(DataUpdateCoordinator):
await self._turn_off_switches()
self._stop_lux_check_timer()
await self._update_state(STATE_DISABLED)
else:
if self._state == STATE_DISABLED:
elif self._state == STATE_DISABLED:
await self._update_state(STATE_IDLE)
def set_motion_filter(self, value: float) -> None:
@@ -158,10 +153,8 @@ class MotionLightCoordinator(DataUpdateCoordinator):
def set_manual_idle_timeout(self, value: float) -> None:
self.manual_idle_timeout = int(value)
self.logger.info("Manual idle timeout set to %d sec", self.manual_idle_timeout)
if self.manual_idle_timeout <= 0 and self._manual_idle_timer:
self.logger.info("Manual idle timeout set to 0, cancelling active timer")
self._manual_idle_timer()
self._manual_idle_timer = None
if self.manual_idle_timeout <= 0:
self._cancel_timer("_manual_idle_timer")
def set_manual_off_cooldown(self, value: float) -> None:
self.manual_off_cooldown = int(value)
@@ -172,24 +165,17 @@ class MotionLightCoordinator(DataUpdateCoordinator):
self.logger.info("Respect manual set to %s", value)
async def async_setup(self):
"""Set up the coordinator."""
self.logger.info("Setting up Motion Light coordinator")
self.logger.info("Switches: %s, Main sensors: %s, Extend sensors: %s, Lux: %s",
self.switch_entities, self.main_sensors, self.extend_sensors, self.lux_sensor)
# Проверяем состояние force_stop_sensor при старте
if self.force_stop_sensor:
force_stop_state_obj = self.hass.states.get(self.force_stop_sensor)
if force_stop_state_obj and force_stop_state_obj.state == self.force_stop_state:
state_obj = self.hass.states.get(self.force_stop_sensor)
if state_obj and state_obj.state == self.force_stop_state:
self.logger.info("Force stop sensor is already in stop state at startup")
await self._update_state(STATE_FORCE_STOPPED)
await self._start_listening()
return
any_on = any(
self.hass.states.get(s) and self.hass.states.get(s).state == "on"
for s in self.switch_entities
)
any_on = any(self.hass.states.get(s) and self.hass.states.get(s).state == "on" for s in self.switch_entities)
if any_on:
self.logger.info("Some switches are ON at startup, entering manual_on")
await self._enter_manual_on()
@@ -199,632 +185,327 @@ class MotionLightCoordinator(DataUpdateCoordinator):
await self._start_listening()
async def async_shutdown(self):
"""Shut down the coordinator."""
self.logger.info("Shutting down Motion Light coordinator")
await self._stop_listening()
self._cancel_all_timers()
async def _update_state(self, state, **attributes):
"""Update state and attributes."""
old_state = self._state
self._state = state
self._attributes.update(attributes)
self.logger.debug("State changed: %s → %s", old_state, state)
self.async_set_updated_data({
"state": state,
"attributes": self.attributes,
})
self.async_set_updated_data({"state": state, "attributes": self.attributes})
async def _start_listening(self):
"""Start listening to sensors."""
self.logger.info("Starting to listen to main sensors: %s", self.main_sensors)
for sensor in self.main_sensors:
unsub = async_track_state_change_event(
self.hass, [sensor], self._main_sensor_state_changed
)
self._unsub_main_listeners.append(unsub)
self.logger.info("Starting to listen to extend sensors: %s", self.extend_sensors)
self._unsub_main_listeners.append(async_track_state_change_event(self.hass, [sensor], self._main_sensor_state_changed))
for sensor in self.extend_sensors:
unsub = async_track_state_change_event(
self.hass, [sensor], self._extend_sensor_state_changed
)
self._unsub_extend_listeners.append(unsub)
self.logger.info("Starting to listen to target switches: %s", self.switch_entities)
self._unsub_extend_listeners.append(async_track_state_change_event(self.hass, [sensor], self._extend_sensor_state_changed))
for switch in self.switch_entities:
unsub = async_track_state_change_event(
self.hass, [switch], self._target_state_changed
)
self._unsub_target_listeners.append(unsub)
self._unsub_target_listeners.append(async_track_state_change_event(self.hass, [switch], self._target_state_changed))
if self.force_stop_sensor:
self.logger.info("Force stop sensor: %s → %s",
self.force_stop_sensor, self.force_stop_state)
self._unsub_force_stop_listener = async_track_state_change_event(
self.hass, [self.force_stop_sensor], self._force_stop_state_changed
)
self._unsub_force_stop_listener = async_track_state_change_event(self.hass, [self.force_stop_sensor], self._force_stop_state_changed)
async def _stop_listening(self):
"""Stop listening to sensors."""
for unsub in self._unsub_main_listeners:
for unsub in self._unsub_main_listeners + self._unsub_extend_listeners + self._unsub_target_listeners:
unsub()
self._unsub_main_listeners = []
for unsub in self._unsub_extend_listeners:
unsub()
self._unsub_extend_listeners = []
for unsub in self._unsub_target_listeners:
unsub()
self._unsub_target_listeners = []
self._unsub_main_listeners = self._unsub_extend_listeners = self._unsub_target_listeners = []
if self._unsub_force_stop_listener:
self._unsub_force_stop_listener()
self._unsub_force_stop_listener = None
@callback
def _main_sensor_state_changed(self, event):
"""Handle main sensor state change."""
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if not new_state:
return
sensor_entity = event.data.get("entity_id")
old_s = old_state.state if old_state else None
if not new_state: return
old_s = event.data.get("old_state").state if event.data.get("old_state") else None
new_s = new_state.state
self.logger.debug("Main sensor event: %s %s → %s", sensor_entity, old_s, new_s)
if new_s == "on" and old_s != "on":
self.logger.debug("Main sensor FRONT UP: %s", sensor_entity)
self.hass.async_create_task(self._handle_main_sensor_on(sensor_entity))
self.hass.async_create_task(self._handle_sensor_on(event.data["entity_id"], is_main=True))
elif old_s == "on" and new_s != "on":
self.logger.debug("Main sensor FRONT DOWN: %s", sensor_entity)
self.hass.async_create_task(self._handle_main_sensor_off())
@callback
def _extend_sensor_state_changed(self, event):
"""Handle extend sensor state change."""
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if not new_state:
return
sensor_entity = event.data.get("entity_id")
old_s = old_state.state if old_state else None
if not new_state: return
old_s = event.data.get("old_state").state if event.data.get("old_state") else None
new_s = new_state.state
self.logger.debug("Extend sensor event: %s %s → %s", sensor_entity, old_s, new_s)
if new_s == "on" and old_s != "on":
self.logger.debug("Extend sensor FRONT UP: %s", sensor_entity)
self.hass.async_create_task(self._handle_extend_sensor_on(sensor_entity))
self.hass.async_create_task(self._handle_sensor_on(event.data["entity_id"], is_main=False))
async def _handle_main_sensor_on(self, triggered_by):
"""Handle main sensor ON - can turn on light."""
if not self._enabled:
self.logger.debug("Integration disabled, ignoring")
async def _handle_sensor_on(self, triggered_by: str, is_main: bool):
"""Universal handler for sensor ON events (main or extend)."""
if not self._enabled or self._state == STATE_FORCE_STOPPED or self._state == STATE_MANUAL_OFF_COOLDOWN:
return
# БЛОКИРОВЩИК: Игнорируем движение, если активна принудительная остановка
if self._state == STATE_FORCE_STOPPED:
self.logger.debug("Ignoring during force_stopped")
return
self.logger.debug("Main sensor ON by %s, current state: %s", triggered_by, self._state)
if self._state == STATE_MANUAL_OFF_COOLDOWN:
self.logger.debug("Ignoring during manual_off_cooldown")
return
sensor_type = "Main" if is_main else "Extend"
self.logger.debug("%s sensor ON by %s, current state: %s", sensor_type, triggered_by, self._state)
if self._state == STATE_MANUAL_ON:
self.logger.debug("During manual_on, resetting idle timer")
if is_main:
all_on = all(self.hass.states.get(s) and self.hass.states.get(s).state == "on" for s in self.switch_entities)
if not all_on:
self.logger.info("Motion during manual_on, turning on remaining switches and switching to auto mode.")
self._cancel_timer("_manual_idle_timer")
await self._turn_on_switches()
await self._update_state(STATE_ON, triggered_by=triggered_by)
return
if self.manual_idle_timeout > 0:
self._reset_manual_idle_timer()
return
if self._state in (STATE_ON, STATE_ACTIVE):
self.logger.debug("Already on/active, updating time")
if self._state == STATE_ON:
self._attributes["last_motion_time"] = dt_util.utcnow().isoformat()
return
if self._state == STATE_ACTIVE:
self._attributes["last_motion_time"] = dt_util.utcnow().isoformat()
self._cancel_timer("_off_delay_timer")
self._start_timer("_off_delay_timer", self.off_delay, self._turn_off_after_delay)
return
if self._state == STATE_DETECTING:
self.logger.debug("Already detecting, ignoring")
return
if self._state == STATE_DELAYING:
self.logger.info("Main sensor ON during off-delay, extending")
if self._off_delay_timer:
self._off_delay_timer()
self._off_delay_timer = None
self.logger.info("%s sensor ON during off-delay, extending", sensor_type)
self._cancel_timer("_off_delay_timer")
await self._update_state(STATE_ACTIVE)
if self.off_delay > 0:
self._off_delay_timer = async_call_later(
self.hass,
self.off_delay,
self._turn_off_after_delay,
)
self._start_timer("_off_delay_timer", self.off_delay, self._turn_off_after_delay)
return
if self._off_delay_timer:
self._off_delay_timer()
self._off_delay_timer = None
# IDLE state
if not is_main:
return # Extend sensors cannot turn on light from idle
await self._update_state(STATE_DETECTING, triggered_by=triggered_by)
if self.motion_filter > 0:
self.logger.debug("Starting motion filter timer for %d seconds", self.motion_filter)
try:
self._motion_detect_timer = async_call_later(
self.hass,
self.motion_filter,
self._turn_on_after_filter,
)
except Exception as err:
self.logger.error("Failed to create motion filter timer: %s", err)
await self._turn_on_after_filter(None)
self._start_timer("_motion_detect_timer", self.motion_filter, self._turn_on_after_filter)
else:
await self._turn_on_after_filter(None)
async def _handle_main_sensor_off(self):
"""Handle main sensor OFF - start delay."""
self.logger.debug("Main sensor OFF, current state: %s", self._state)
if self._is_any_main_sensor_active():
self.logger.debug("Some main sensor still active, ignoring")
if self._is_any_sensor_from_list_active(self.main_sensors):
return
self.logger.debug("All main sensors are off")
self._stop_lux_check_timer()
if self._state in (STATE_ON, STATE_ACTIVE):
self.logger.debug("Transitioning to DELAYING state")
await self._update_state(STATE_DELAYING)
if self._off_delay_timer:
self._off_delay_timer()
self._off_delay_timer = None
if self.off_delay > 0:
self.logger.debug("Starting off-delay timer for %d seconds", self.off_delay)
try:
self._off_delay_timer = async_call_later(
self.hass,
self.off_delay,
self._turn_off_after_delay,
)
except Exception as err:
self.logger.error("Failed to create off-delay timer: %s", err)
await self._turn_off_after_delay(None)
else:
self.logger.debug("No off-delay, turning off immediately")
await self._turn_off_after_delay(None)
self._cancel_timer("_off_delay_timer")
self._start_timer("_off_delay_timer", self.off_delay, self._turn_off_after_delay)
elif self._state == STATE_DETECTING:
if self._motion_detect_timer:
self._motion_detect_timer()
self._motion_detect_timer = None
self._cancel_timer("_motion_detect_timer")
await self._update_state(STATE_IDLE)
async def _handle_extend_sensor_on(self, triggered_by):
"""Handle extend sensor ON - only extend, never turn on."""
if not self._enabled:
self.logger.debug("Integration disabled, ignoring")
return
# БЛОКИРОВЩИК: Игнорируем движение, если активна принудительная остановка
if self._state == STATE_FORCE_STOPPED:
self.logger.debug("Ignoring during force_stopped")
return
self.logger.debug("Extend sensor ON by %s, current state: %s", triggered_by, self._state)
if self._state == STATE_IDLE:
self.logger.debug("Extend sensor ignored: in idle state")
return
if self._state == STATE_MANUAL_OFF_COOLDOWN:
self.logger.debug("Ignoring during manual_off_cooldown")
return
if self._state == STATE_MANUAL_ON:
self.logger.debug("During manual_on, resetting idle timer")
if self.manual_idle_timeout > 0:
self._reset_manual_idle_timer()
return
if self._state in (STATE_ON, STATE_ACTIVE):
self.logger.debug("Already on/active, updating time")
self._attributes["last_motion_time"] = dt_util.utcnow().isoformat()
return
if self._state == STATE_DETECTING:
self.logger.debug("Already detecting, ignoring")
return
if self._state == STATE_DELAYING:
self.logger.info("Extend sensor ON during off-delay, extending")
if self._off_delay_timer:
self._off_delay_timer()
self._off_delay_timer = None
await self._update_state(STATE_ACTIVE)
if self.off_delay > 0:
self._off_delay_timer = async_call_later(
self.hass,
self.off_delay,
self._turn_off_after_delay,
)
return
@callback
def _target_state_changed(self, event):
"""Handle target switch state change."""
if not self.respect_manual:
if not self.respect_manual or self._state == STATE_FORCE_STOPPED:
return
# БЛОКИРОВЩИК: Игнорируем ручные переключения, если активна принудительная остановка
if self._state == STATE_FORCE_STOPPED:
self.logger.debug("Ignoring manual change during force_stopped")
return
new_state = event.data.get("new_state")
old_state = event.data.get("old_state")
if not new_state or not old_state:
return
if new_state.state == old_state.state:
if not new_state or not old_state or new_state.state == old_state.state:
return
ctx = new_state.context
if ctx and ctx.id and ctx.id.startswith(f"motion_light_{self.entry.entry_id}_"):
self.logger.debug("State change from our context (%s), ignoring", ctx.id)
return
entity_id = event.data["entity_id"]
self.logger.info("Manual change detected on %s: %s → %s",
entity_id, old_state.state, new_state.state)
self.logger.info("Manual change detected on %s: %s → %s", entity_id, old_state.state, new_state.state)
if new_state.state == "off" and old_state.state == "on":
if self._state in (STATE_ON, STATE_DELAYING, STATE_ACTIVE, STATE_MANUAL_ON):
self.logger.info("Manual OFF during our control → entering cooldown")
self._cancel_off_delay()
self._cancel_timer("_off_delay_timer")
self._cancel_timer("_manual_idle_timer")
self._stop_lux_check_timer()
if self._manual_idle_timer:
self._manual_idle_timer()
self._manual_idle_timer = None
self.hass.async_create_task(self._enter_manual_off_cooldown())
return
if new_state.state == "on" and old_state.state == "off":
elif new_state.state == "on" and old_state.state == "off":
if self._state in (STATE_IDLE, STATE_DETECTING, STATE_MANUAL_OFF_COOLDOWN):
self.logger.info("Manual ON → entering manual_on with idle timeout")
self._cancel_motion_filter()
self._cancel_timer("_motion_detect_timer")
self._stop_lux_check_timer()
self.hass.async_create_task(self._enter_manual_on())
return
@callback
def _force_stop_state_changed(self, event):
"""Handle force stop sensor state change."""
new_state = event.data.get("new_state")
if not new_state:
return
# Если датчик перешел в блокирующее состояние
if not new_state: return
if new_state.state == self.force_stop_state:
self.logger.info("Force stop triggered by %s", event.data.get("entity_id"))
self.hass.async_create_task(self._enter_force_stop())
# Если датчик вышел из блокирующего состояния
else:
self.logger.info("Force stop released by %s", event.data.get("entity_id"))
self.hass.async_create_task(self._exit_force_stop())
async def _enter_force_stop(self):
"""Enter force_stopped state."""
self.logger.info("Entering force_stopped state")
self._cancel_all_timers()
if self._state in (STATE_ON, STATE_DELAYING, STATE_ACTIVE, STATE_MANUAL_ON):
await self._turn_off_switches()
await self._update_state(STATE_FORCE_STOPPED)
async def _exit_force_stop(self):
"""Exit force_stopped state to idle."""
self.logger.info("Exiting force_stopped state")
await self._update_state(STATE_IDLE)
# --- Универсальные методы для сенсоров ---
def _is_any_sensor_from_list_active(self, sensors: list) -> bool:
return any(self.hass.states.get(s) and self.hass.states.get(s).state == "on" for s in sensors)
def _get_active_sensors(self, sensors: list) -> list:
return [s for s in sensors if self.hass.states.get(s) and self.hass.states.get(s).state == "on"]
def _is_any_main_sensor_active(self) -> bool:
"""Check if any main sensor is currently in 'on' state."""
for sensor in self.main_sensors:
state = self.hass.states.get(sensor)
if state and state.state == "on":
return True
return False
return self._is_any_sensor_from_list_active(self.main_sensors)
def _is_any_extend_sensor_active(self) -> bool:
"""Check if any extend sensor is currently in 'on' state."""
for sensor in self.extend_sensors:
state = self.hass.states.get(sensor)
if state and state.state == "on":
return True
return False
return self._is_any_sensor_from_list_active(self.extend_sensors)
def _is_any_sensor_active(self) -> bool:
"""Check if any sensor (main or extend) is active."""
return self._is_any_main_sensor_active() or self._is_any_extend_sensor_active()
def _get_active_main_sensors(self) -> list:
"""Return list of active main sensors."""
return [
s for s in self.main_sensors
if self.hass.states.get(s) and self.hass.states.get(s).state == "on"
]
def _get_active_extend_sensors(self) -> list:
"""Return list of active extend sensors."""
return [
s for s in self.extend_sensors
if self.hass.states.get(s) and self.hass.states.get(s).state == "on"
]
# --- Lux и таймеры ---
def _get_current_lux(self) -> float | None:
"""Get current lux value from sensor."""
if not self.lux_sensor:
return None
if not self.lux_sensor: return None
state = self.hass.states.get(self.lux_sensor)
if not state or state.state in ("unknown", "unavailable"):
return None
try:
return float(state.state)
except (ValueError, TypeError):
return None
if not state or state.state in ("unknown", "unavailable"): return None
try: return float(state.state)
except (ValueError, TypeError): return None
async def _should_turn_on_by_lux(self) -> bool:
"""Check if we should turn on based on lux."""
if not self.lux_sensor:
return True
if self._lux_cooldown_active:
self.logger.debug("Lux cooldown active, ignoring sensor")
return True
if not self.lux_sensor or self._lux_cooldown_active: return True
lux = self._get_current_lux()
if lux is None:
return True
return lux < self.lux_threshold
return lux is None or lux < self.lux_threshold
def _start_lux_check_timer(self):
"""Start periodic lux check timer."""
if not self.lux_sensor:
self.logger.debug("No lux sensor, not starting lux check timer")
return
if self._lux_check_timer:
self.logger.debug("Lux check timer already running")
return
self.logger.info("Starting lux check timer (interval=%d sec)", LUX_CHECK_INTERVAL)
self._lux_check_timer = async_call_later(
self.hass,
LUX_CHECK_INTERVAL,
self._check_lux_periodically,
)
if not self.lux_sensor or self._lux_check_timer: return
self._lux_check_timer = async_call_later(self.hass, LUX_CHECK_INTERVAL, self._check_lux_periodically)
self.async_set_updated_data({"state": self._state, "attributes": self.attributes})
def _stop_lux_check_timer(self):
"""Stop periodic lux check timer."""
if self._lux_check_timer:
self.logger.debug("Stopping lux check timer")
self._lux_check_timer()
self._lux_check_timer = None
self._cancel_timer("_lux_check_timer")
self.async_set_updated_data({"state": self._state, "attributes": self.attributes})
def _start_lux_cooldown(self):
self._lux_cooldown_active = True
if self.lux_cooldown > 0:
self._cancel_timer("_lux_cooldown_timer")
self._lux_cooldown_timer = async_call_later(self.hass, self.lux_cooldown, self._lux_cooldown_expired)
self.async_set_updated_data({"state": self._state, "attributes": self.attributes})
def _cancel_lux_cooldown(self):
if self._lux_cooldown_active:
self._lux_cooldown_active = False
self._cancel_timer("_lux_cooldown_timer")
self.async_set_updated_data({"state": self._state, "attributes": self.attributes})
@callback
def _check_lux_periodically(self, _=None):
"""Check lux periodically."""
self._lux_check_timer = None
if not self._is_any_sensor_active():
self.logger.debug("No sensors active, not checking lux")
if not self._is_any_sensor_active() or self._state not in (STATE_IDLE, STATE_DETECTING):
return
if self._state not in (STATE_IDLE, STATE_DETECTING):
self.logger.debug("State is %s, not checking lux", self._state)
return
lux = self._get_current_lux()
if lux is None:
self.logger.debug("Lux value unavailable, restarting timer")
self._start_lux_check_timer()
return
self.logger.debug("[Lux check] lux=%.1f, threshold=%d", lux, self.lux_threshold)
if lux < self.lux_threshold:
self.logger.info("[Lux check] Dark enough, turning on lights")
self.hass.async_create_task(self._turn_on_after_lux_check())
else:
self.logger.debug("[Lux check] Still bright, restarting timer")
self._start_lux_check_timer()
async def _turn_on_after_lux_check(self):
"""Turn on lights after periodic lux check."""
if self._lux_cooldown_active:
self.logger.debug("Lux cooldown active, not turning on")
if self._lux_cooldown_active or self._state not in (STATE_IDLE, STATE_DETECTING) or not self._is_any_sensor_active():
return
if self._state not in (STATE_IDLE, STATE_DETECTING):
self.logger.debug("State changed to %s, not turning on", self._state)
return
if not self._is_any_sensor_active():
self.logger.debug("No sensors active, not turning on")
return
triggered_by = self._attributes.get("triggered_by")
self._cancel_lux_cooldown()
await self._turn_on_switches()
await self._update_state(STATE_ON, triggered_by=triggered_by)
await self._update_state(STATE_ON, triggered_by=self._attributes.get("triggered_by"))
async def _turn_on_after_filter(self, _=None):
"""Turn on after motion filter."""
self.logger.debug("Turning on after filter")
self._motion_detect_timer = None
self._cancel_timer("_motion_detect_timer")
if not await self._should_turn_on_by_lux():
self.logger.debug("Too bright, not turning on — starting lux check timer")
await self._update_state(STATE_IDLE)
self._start_lux_check_timer()
return
triggered_by = self._attributes.get("triggered_by")
self._cancel_lux_cooldown()
await self._turn_on_switches()
await self._update_state(STATE_ON, triggered_by=triggered_by)
await self._update_state(STATE_ON, triggered_by=self._attributes.get("triggered_by"))
async def _turn_off_after_delay(self, _=None):
"""Turn off after off-delay."""
self.logger.debug("Off-delay timer expired")
self._off_delay_timer = None
self._cancel_timer("_off_delay_timer")
if self._is_any_sensor_active():
self.logger.info("Sensor is still active — staying on")
await self._update_state(STATE_ON)
return
self.logger.info("No sensors active, turning off")
await self._turn_off_switches()
self._lux_cooldown_active = True
if self.lux_cooldown > 0:
self._lux_cooldown_timer = async_call_later(
self.hass,
self.lux_cooldown,
self._lux_cooldown_expired,
)
self.logger.info("Lux cooldown started for %d sec", self.lux_cooldown)
await self._start_lux_cooldown()
await self._update_state(STATE_OFF)
async_call_later(self.hass, 1, self._transition_to_idle)
@callback
def _transition_to_idle(self, _=None):
"""Transition from off to idle."""
if self._state == STATE_OFF:
self.logger.debug("Transitioning from off to idle")
self.hass.async_create_task(self._update_state(STATE_IDLE))
# --- Управление переключателями ---
async def _turn_on_switches(self) -> None:
"""Turn on switches/lights with our context."""
ctx = Context(
id=f"motion_light_{self.entry.entry_id}_{dt_util.utcnow().timestamp()}"
)
self.logger.info("Turning ON entities: %s", self.switch_entities)
await self.hass.services.async_call(
"homeassistant",
"turn_on",
{"entity_id": self.switch_entities},
context=ctx,
)
ctx = Context(id=f"motion_light_{self.entry.entry_id}_{dt_util.utcnow().timestamp()}")
await self.hass.services.async_call("homeassistant", "turn_on", {"entity_id": self.switch_entities}, context=ctx)
async def _turn_off_switches(self) -> None:
"""Turn off switches/lights with our context."""
ctx = Context(
id=f"motion_light_{self.entry.entry_id}_{dt_util.utcnow().timestamp()}"
)
self.logger.info("Turning OFF entities: %s", self.switch_entities)
await self.hass.services.async_call(
"homeassistant",
"turn_off",
{"entity_id": self.switch_entities},
context=ctx,
)
ctx = Context(id=f"motion_light_{self.entry.entry_id}_{dt_util.utcnow().timestamp()}")
await self.hass.services.async_call("homeassistant", "turn_off", {"entity_id": self.switch_entities}, context=ctx)
# --- Ручное управление ---
async def _enter_manual_on(self):
"""Enter manual_on state."""
self._cancel_lux_cooldown()
await self._update_state(STATE_MANUAL_ON)
if self.manual_idle_timeout > 0:
self._reset_manual_idle_timer()
else:
self.logger.info("Manual idle timeout is 0. 'Forgot to turn off' timer is disabled.")
def _reset_manual_idle_timer(self):
"""Reset manual idle timer."""
if self.manual_idle_timeout <= 0:
return
if self._manual_idle_timer:
self._manual_idle_timer()
self._manual_idle_timer = async_call_later(
self.hass,
self.manual_idle_timeout,
self._manual_idle_timeout_expired,
)
self.logger.debug("Manual idle timer reset for %d sec", self.manual_idle_timeout)
if self.manual_idle_timeout <= 0: return
self._cancel_timer("_manual_idle_timer")
self._manual_idle_timer = async_call_later(self.hass, self.manual_idle_timeout, self._manual_idle_timeout_expired)
@callback
def _manual_idle_timeout_expired(self, _=None):
"""Manual idle timeout expired — turn off."""
self._manual_idle_timer = None
self.logger.info("Manual idle timeout expired, turning off")
self.hass.async_create_task(self._turn_off_switches_manual())
async def _turn_off_switches_manual(self):
"""Turn off switches/lights in manual mode."""
self.logger.info("Turning OFF entities (manual timeout): %s", self.switch_entities)
await self.hass.services.async_call(
"homeassistant",
"turn_off",
{"entity_id": self.switch_entities},
)
await self.hass.services.async_call("homeassistant", "turn_off", {"entity_id": self.switch_entities})
await self._start_lux_cooldown()
await self._update_state(STATE_OFF)
async_call_later(self.hass, 1, self._transition_to_idle)
async def _enter_manual_off_cooldown(self):
"""Enter manual_off_cooldown state."""
await self._start_lux_cooldown()
await self._update_state(STATE_MANUAL_OFF_COOLDOWN)
if self._manual_off_cooldown_timer:
self._manual_off_cooldown_timer()
if self.manual_off_cooldown > 0:
self._manual_off_cooldown_timer = async_call_later(
self.hass,
self.manual_off_cooldown,
self._manual_off_cooldown_expired,
)
self.logger.info("Manual off cooldown started for %d sec",
self.manual_off_cooldown)
self._start_timer("_manual_off_cooldown_timer", self.manual_off_cooldown, self._manual_off_cooldown_expired)
else:
await self._update_state(STATE_IDLE)
@callback
def _manual_off_cooldown_expired(self, _=None):
"""Manual off cooldown expired."""
self._manual_off_cooldown_timer = None
self.logger.info("Manual off cooldown expired → idle")
self.hass.async_create_task(self._update_state(STATE_IDLE))
@callback
def _lux_cooldown_expired(self, _=None):
"""Lux cooldown expired."""
self._lux_cooldown_active = False
self._lux_cooldown_timer = None
self.logger.info("Lux cooldown expired")
self._cancel_timer("_lux_cooldown_timer")
self.async_set_updated_data({"state": self._state, "attributes": self.attributes})
# --- Универсальные методы управления таймерами ---
def _cancel_timer(self, timer_attr: str):
timer = getattr(self, timer_attr, None)
if timer:
timer()
setattr(self, timer_attr, None)
def _start_timer(self, timer_attr: str, delay: float, callback):
if delay > 0:
setattr(self, timer_attr, async_call_later(self.hass, delay, callback))
def _cancel_all_timers(self):
"""Cancel all timers."""
if self._motion_detect_timer:
self._motion_detect_timer()
self._motion_detect_timer = None
if self._off_delay_timer:
self._off_delay_timer()
self._off_delay_timer = None
if self._lux_cooldown_timer:
self._lux_cooldown_timer()
self._lux_cooldown_timer = None
if self._lux_check_timer:
self._lux_check_timer()
self._lux_check_timer = None
if self._manual_idle_timer:
self._manual_idle_timer()
self._manual_idle_timer = None
if self._manual_off_cooldown_timer:
self._manual_off_cooldown_timer()
self._manual_off_cooldown_timer = None
def _cancel_motion_filter(self):
"""Cancel motion filter timer."""
if self._motion_detect_timer:
self._motion_detect_timer()
self._motion_detect_timer = None
def _cancel_off_delay(self):
"""Cancel off-delay timer."""
if self._off_delay_timer:
self._off_delay_timer()
self._off_delay_timer = None
self._cancel_timer("_motion_detect_timer")
self._cancel_timer("_off_delay_timer")
self._cancel_timer("_lux_cooldown_timer")
self._cancel_timer("_lux_check_timer")
self._cancel_timer("_manual_idle_timer")
self._cancel_timer("_manual_off_cooldown_timer")
+6 -6
View File
@@ -1,12 +1,12 @@
{
"domain": "motion_light",
"name": "Motion Light",
"version": "1.0.2",
"documentation": "https://github.com/sirmrak/motion_light",
"issue_tracker": "https://github.com/sirmrak/motion_light/issues",
"dependencies": [],
"codeowners": ["@sirmrak"],
"requirements": [],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/sirmrak/motion_light",
"iot_class": "local_push",
"config_flow": true
"issue_tracker": "https://github.com/sirmrak/motion_light/issues",
"requirements": [],
"version": "1.2.0-beta.2"
}
+32
View File
@@ -0,0 +1,32 @@
[tool.semantic_release]
tag_format = "v{version}"
version_variables = [
"custom_components/motion_light/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"]