Compare commits

...
44 Commits
Author SHA1 Message Date
sirmrak ef86c8ad3a Merge pull request 'Release: merge dev to main' (#3) from dev into main
Update Version / Update Version (push) Successful in 6s
Reviewed-on: #3
2026-07-22 10:16:26 +03:00
semantic-release a900385989 chore(release): v1.2.0-beta.3
Update Version / Update Version (push) Successful in 5s
2026-07-22 06:48:12 +00:00
sirmrak 9178687d8a fix: await self._start_lux_cooldown()
Update Version / Update Version (push) Successful in 6s
2026-07-22 09:48:04 +03:00
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
10 changed files with 609 additions and 723 deletions
+6 -7
View File
@@ -5,14 +5,14 @@ on:
branches: branches:
- main - main
- master - master
- dev
env:
GIT_SSL_NO_VERIFY: true
jobs: jobs:
update-version: update-version:
name: Update Version name: Update Version
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Пропускаем запуск, если коммит сделал сам бот (избегаем бесконечного цикла)
if: github.actor != 'gitea-actions[bot]' && github.actor != 'Gitea Actions Bot'
permissions: permissions:
contents: write contents: write
@@ -39,9 +39,8 @@ jobs:
- name: Run semantic-release - name: Run semantic-release
env: env:
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }} GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
PYTHONHTTPSVERIFY: "0"
REQUESTS_CA_BUNDLE: ""
CURL_CA_BUNDLE: ""
run: | run: |
echo "🚀 Запускаем semantic-release..." unset GITHUB_SERVER_URL
unset GITHUB_API_URL
unset GITHUB_REPOSITORY
semantic-release version semantic-release version
+109 -10
View File
@@ -1,14 +1,83 @@
name: Create Release name: Test and Release
on: on:
push: push:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch:
jobs: jobs:
release: validate:
name: Create GitHub Release name: Validate Integration
runs-on: ubuntu-latest 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: permissions:
contents: write contents: write
@@ -24,9 +93,25 @@ jobs:
TAG=${GITHUB_REF#refs/tags/} TAG=${GITHUB_REF#refs/tags/}
echo "tag=$TAG" >> $GITHUB_OUTPUT echo "tag=$TAG" >> $GITHUB_OUTPUT
TAG_MSG=$(git tag -l --format='%(contents)' $TAG 2>/dev/null || git log -1 --pretty=%B) # Проверяем, это beta или stable релиз
echo "message<<EOF" >> $GITHUB_OUTPUT if [[ "$TAG" == *"beta"* ]]; then
echo "$TAG_MSG" >> $GITHUB_OUTPUT 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 echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release - name: Create GitHub Release
@@ -34,13 +119,27 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
TAG="${{ steps.tag.outputs.tag }}" TAG="${{ steps.tag.outputs.tag }}"
MSG="${{ steps.tag.outputs.message }}" COMMITS="${{ steps.tag.outputs.commits }}"
IS_BETA="${{ steps.tag.outputs.is_beta }}"
echo "🚀 Создаём релиз $TAG в GitHub..." 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" \ gh release create "$TAG" \
--title "Release $TAG" \ --title "$TITLE" \
--notes "$MSG" \ --notes-file /tmp/release_notes.md \
--target main --target main \
${IS_BETA:+--prerelease}
echo "✅ Релиз создан!" echo "✅ Релиз создан!"
+99
View File
@@ -2,6 +2,105 @@
<!-- version list --> <!-- version list -->
## v1.2.0-beta.3 (2026-07-22)
### Bug Fixes
- Await self._start_lux_cooldown()
([`9178687`](https://git.darkmode.duckdns.org/sirmrak/motion_light/commit/9178687d8a74a5a0e537f14dcd828c03ca7da490))
## 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) ## v1.0.9 (2026-07-09)
### Bug Fixes ### Bug Fixes
+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,4 +1,4 @@
"""The Motion Light integration.""" """The Motion Light integration. Dev"""
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
+189 -504
View File
@@ -35,15 +35,18 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class PrefixLoggerAdapter(logging.LoggerAdapter): class PrefixLoggerAdapter(logging.LoggerAdapter):
"""Logger adapter that adds a prefix to log messages.""" """Logger adapter that adds a prefix to log messages."""
def process(self, msg, kwargs): def process(self, msg, kwargs):
return f"[{self.extra.get('name', 'Unknown')}] {msg}", kwargs return f"[{self.extra.get('name', 'Unknown')}] {msg}", kwargs
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.""" """Get config value from options first, then from data, then default."""
return entry.options.get(key, entry.data.get(key, default)) return entry.options.get(key, entry.data.get(key, default))
class MotionLightCoordinator(DataUpdateCoordinator): class MotionLightCoordinator(DataUpdateCoordinator):
"""Motion Light coordinator.""" """Motion Light coordinator."""
@@ -52,14 +55,9 @@ class MotionLightCoordinator(DataUpdateCoordinator):
self.entry = entry self.entry = entry
self.logger = PrefixLoggerAdapter(_LOGGER, {"name": entry.title}) self.logger = PrefixLoggerAdapter(_LOGGER, {"name": entry.title})
super().__init__( super().__init__(hass, self.logger, name=f"Motion Light {entry.title}")
hass,
self.logger,
name=f"Motion Light {entry.title}",
)
self.hass = hass self.hass = hass
self.switch_entities = _get_config_value(entry, CONF_SWITCH_ENTITIES, []) self.switch_entities = _get_config_value(entry, CONF_SWITCH_ENTITIES, [])
self.main_sensors = _get_config_value(entry, CONF_MAIN_SENSORS, []) self.main_sensors = _get_config_value(entry, CONF_MAIN_SENSORS, [])
self.extend_sensors = _get_config_value(entry, CONF_EXTEND_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_idle_timer = None
self._manual_off_cooldown_timer = None self._manual_off_cooldown_timer = None
# Context & Flags # Flags
self._lux_cooldown_active = False self._lux_cooldown_active = False
@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."""
attrs = self._attributes.copy() attrs = self._attributes.copy()
attrs["switch_entities"] = self.switch_entities attrs.update({
attrs["main_sensors"] = self.main_sensors "switch_entities": self.switch_entities,
attrs["extend_sensors"] = self.extend_sensors "main_sensors": self.main_sensors,
attrs["lux_sensor"] = self.lux_sensor "extend_sensors": self.extend_sensors,
attrs["force_stop_sensor"] = self.force_stop_sensor "lux_sensor": self.lux_sensor,
attrs["force_stop_state"] = self.force_stop_state "force_stop_sensor": self.force_stop_sensor,
attrs["force_stop_active"] = self._state == STATE_FORCE_STOPPED "force_stop_state": self.force_stop_state,
attrs["active_main_sensors"] = self._get_active_main_sensors() "force_stop_active": self._state == STATE_FORCE_STOPPED,
attrs["active_extend_sensors"] = self._get_active_extend_sensors() "active_main_sensors": self._get_active_sensors(self.main_sensors),
attrs["lux_value"] = self._get_current_lux() "active_extend_sensors": self._get_active_sensors(self.extend_sensors),
attrs["lux_cooldown_active"] = self._lux_cooldown_active "lux_value": self._get_current_lux(),
attrs["lux_check_active"] = self._lux_check_timer is not None "lux_cooldown_active": self._lux_cooldown_active,
"lux_check_active": self._lux_check_timer is not None,
})
return attrs return attrs
@property @property
def is_enabled(self) -> bool: def is_enabled(self) -> bool:
"""Return if integration is enabled."""
return self._enabled return self._enabled
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
self.logger.info("Motion Light %s", "enabled" if enabled else "disabled") self.logger.info("Motion Light %s", "enabled" if enabled else "disabled")
if not enabled: if not enabled:
@@ -135,8 +131,7 @@ class MotionLightCoordinator(DataUpdateCoordinator):
await self._turn_off_switches() await self._turn_off_switches()
self._stop_lux_check_timer() self._stop_lux_check_timer()
await self._update_state(STATE_DISABLED) await self._update_state(STATE_DISABLED)
else: elif self._state == STATE_DISABLED:
if self._state == STATE_DISABLED:
await self._update_state(STATE_IDLE) await self._update_state(STATE_IDLE)
def set_motion_filter(self, value: float) -> None: def set_motion_filter(self, value: float) -> None:
@@ -158,10 +153,8 @@ class MotionLightCoordinator(DataUpdateCoordinator):
def set_manual_idle_timeout(self, value: float) -> None: def set_manual_idle_timeout(self, value: float) -> None:
self.manual_idle_timeout = int(value) self.manual_idle_timeout = int(value)
self.logger.info("Manual idle timeout set to %d sec", self.manual_idle_timeout) 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: if self.manual_idle_timeout <= 0:
self.logger.info("Manual idle timeout set to 0, cancelling active timer") self._cancel_timer("_manual_idle_timer")
self._manual_idle_timer()
self._manual_idle_timer = None
def set_manual_off_cooldown(self, value: float) -> None: def set_manual_off_cooldown(self, value: float) -> None:
self.manual_off_cooldown = int(value) self.manual_off_cooldown = int(value)
@@ -172,24 +165,17 @@ class MotionLightCoordinator(DataUpdateCoordinator):
self.logger.info("Respect manual set to %s", value) self.logger.info("Respect manual set to %s", value)
async def async_setup(self): async def async_setup(self):
"""Set up the coordinator."""
self.logger.info("Setting up Motion Light 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: if self.force_stop_sensor:
force_stop_state_obj = self.hass.states.get(self.force_stop_sensor) 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: if state_obj and state_obj.state == self.force_stop_state:
self.logger.info("Force stop sensor is already in stop state at startup") self.logger.info("Force stop sensor is already in stop state at startup")
await self._update_state(STATE_FORCE_STOPPED) await self._update_state(STATE_FORCE_STOPPED)
await self._start_listening() await self._start_listening()
return return
any_on = any( any_on = any(self.hass.states.get(s) and self.hass.states.get(s).state == "on" for s in self.switch_entities)
self.hass.states.get(s) and self.hass.states.get(s).state == "on"
for s in self.switch_entities
)
if any_on: if any_on:
self.logger.info("Some switches are ON at startup, entering manual_on") self.logger.info("Some switches are ON at startup, entering manual_on")
await self._enter_manual_on() await self._enter_manual_on()
@@ -199,644 +185,343 @@ class MotionLightCoordinator(DataUpdateCoordinator):
await self._start_listening() await self._start_listening()
async def async_shutdown(self): async def async_shutdown(self):
"""Shut down the coordinator."""
self.logger.info("Shutting down Motion Light coordinator") self.logger.info("Shutting down Motion Light coordinator")
await self._stop_listening() await self._stop_listening()
self._cancel_all_timers() self._cancel_all_timers()
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)
self.logger.debug("State changed: %s → %s", old_state, state) self.logger.debug("State changed: %s → %s", old_state, state)
self.async_set_updated_data({ self.async_set_updated_data({"state": state, "attributes": self.attributes})
"state": state,
"attributes": self.attributes,
})
async def _start_listening(self): 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: for sensor in self.main_sensors:
unsub = async_track_state_change_event( self._unsub_main_listeners.append(async_track_state_change_event(self.hass, [sensor], self._main_sensor_state_changed))
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)
for sensor in self.extend_sensors: for sensor in self.extend_sensors:
unsub = async_track_state_change_event( self._unsub_extend_listeners.append(async_track_state_change_event(self.hass, [sensor], self._extend_sensor_state_changed))
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)
for switch in self.switch_entities: for switch in self.switch_entities:
unsub = async_track_state_change_event( self._unsub_target_listeners.append(async_track_state_change_event(self.hass, [switch], self._target_state_changed))
self.hass, [switch], self._target_state_changed
)
self._unsub_target_listeners.append(unsub)
if self.force_stop_sensor: if self.force_stop_sensor:
self.logger.info("Force stop sensor: %s → %s", self._unsub_force_stop_listener = async_track_state_change_event(self.hass, [self.force_stop_sensor], self._force_stop_state_changed)
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
)
async def _stop_listening(self): async def _stop_listening(self):
"""Stop listening to sensors.""" for unsub in self._unsub_main_listeners + self._unsub_extend_listeners + self._unsub_target_listeners:
for unsub in self._unsub_main_listeners:
unsub() unsub()
self._unsub_main_listeners = [] self._unsub_main_listeners = self._unsub_extend_listeners = self._unsub_target_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 = []
if self._unsub_force_stop_listener: if self._unsub_force_stop_listener:
self._unsub_force_stop_listener() self._unsub_force_stop_listener()
self._unsub_force_stop_listener = None self._unsub_force_stop_listener = None
@callback @callback
def _main_sensor_state_changed(self, event): 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") new_state = event.data.get("new_state")
if not new_state: if not new_state: return
return old_s = event.data.get("old_state").state if event.data.get("old_state") else None
sensor_entity = event.data.get("entity_id")
old_s = old_state.state if old_state else None
new_s = new_state.state 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": 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_sensor_on(event.data["entity_id"], is_main=True))
self.hass.async_create_task(self._handle_main_sensor_on(sensor_entity))
elif old_s == "on" and new_s != "on": 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()) self.hass.async_create_task(self._handle_main_sensor_off())
@callback @callback
def _extend_sensor_state_changed(self, event): 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") new_state = event.data.get("new_state")
if not new_state: if not new_state: return
return old_s = event.data.get("old_state").state if event.data.get("old_state") else None
sensor_entity = event.data.get("entity_id")
old_s = old_state.state if old_state else None
new_s = new_state.state 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": 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_sensor_on(event.data["entity_id"], is_main=False))
self.hass.async_create_task(self._handle_extend_sensor_on(sensor_entity))
async def _handle_main_sensor_on(self, triggered_by): async def _handle_sensor_on(self, triggered_by: str, is_main: bool):
"""Handle main sensor ON - can turn on light.""" """Universal handler for sensor ON events (main or extend)."""
if not self._enabled: if not self._enabled or self._state == STATE_FORCE_STOPPED or self._state == STATE_MANUAL_OFF_COOLDOWN:
self.logger.debug("Integration disabled, ignoring")
return return
# БЛОКИРОВЩИК: Игнорируем движение, если активна принудительная остановка sensor_type = "Main" if is_main else "Extend"
if self._state == STATE_FORCE_STOPPED: self.logger.debug("%s sensor ON by %s, current state: %s", sensor_type, triggered_by, self._state)
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
if self._state == STATE_MANUAL_ON: 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: if self.manual_idle_timeout > 0:
self._reset_manual_idle_timer() self._reset_manual_idle_timer()
return return
if self._state in (STATE_ON, STATE_ACTIVE): if self._state == STATE_ON:
self.logger.debug("Already on/active, updating time")
self._attributes["last_motion_time"] = dt_util.utcnow().isoformat() self._attributes["last_motion_time"] = dt_util.utcnow().isoformat()
return 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: if self._state == STATE_DETECTING:
self.logger.debug("Already detecting, ignoring")
return return
if self._state == STATE_DELAYING: if self._state == STATE_DELAYING:
self.logger.info("Main sensor ON during off-delay, extending") self.logger.info("%s sensor ON during off-delay, extending", sensor_type)
if self._off_delay_timer: self._cancel_timer("_off_delay_timer")
self._off_delay_timer()
self._off_delay_timer = None
await self._update_state(STATE_ACTIVE) await self._update_state(STATE_ACTIVE)
if self.off_delay > 0: self._start_timer("_off_delay_timer", self.off_delay, self._turn_off_after_delay)
self._off_delay_timer = async_call_later(
self.hass,
self.off_delay,
self._turn_off_after_delay,
)
return return
if self._off_delay_timer: # IDLE state
self._off_delay_timer() if not is_main:
self._off_delay_timer = None return # Extend sensors cannot turn on light from idle
await self._update_state(STATE_DETECTING, triggered_by=triggered_by) await self._update_state(STATE_DETECTING, triggered_by=triggered_by)
if self.motion_filter > 0: if self.motion_filter > 0:
self.logger.debug("Starting motion filter timer for %d seconds", self.motion_filter) self._start_timer("_motion_detect_timer", self.motion_filter, self._turn_on_after_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)
else: else:
await self._turn_on_after_filter(None) await self._turn_on_after_filter(None)
async def _handle_main_sensor_off(self): async def _handle_main_sensor_off(self):
"""Handle main sensor OFF - start delay.""" if self._is_any_sensor_from_list_active(self.main_sensors):
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")
return return
self.logger.debug("All main sensors are off")
self._stop_lux_check_timer() self._stop_lux_check_timer()
if self._state in (STATE_ON, STATE_ACTIVE): if self._state in (STATE_ON, STATE_ACTIVE):
self.logger.debug("Transitioning to DELAYING state")
await self._update_state(STATE_DELAYING) await self._update_state(STATE_DELAYING)
if self._off_delay_timer: self._cancel_timer("_off_delay_timer")
self._off_delay_timer() self._start_timer("_off_delay_timer", self.off_delay, self._turn_off_after_delay)
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)
elif self._state == STATE_DETECTING: elif self._state == STATE_DETECTING:
if self._motion_detect_timer: self._cancel_timer("_motion_detect_timer")
self._motion_detect_timer()
self._motion_detect_timer = None
await self._update_state(STATE_IDLE) 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 @callback
def _target_state_changed(self, event): def _target_state_changed(self, event):
"""Handle target switch state change.""" if not self.respect_manual or self._state == STATE_FORCE_STOPPED:
if not self.respect_manual:
return return
# БЛОКИРОВЩИК: Игнорируем ручные переключения, если активна принудительная остановка
if self._state == STATE_FORCE_STOPPED:
self.logger.debug("Ignoring manual change during force_stopped")
return
new_state = event.data.get("new_state") new_state = event.data.get("new_state")
old_state = event.data.get("old_state") old_state = event.data.get("old_state")
if not new_state or not old_state: if not new_state or not old_state or new_state.state == old_state.state:
return
if new_state.state == old_state.state:
return return
ctx = new_state.context ctx = new_state.context
if ctx and ctx.id and ctx.id.startswith(f"motion_light_{self.entry.entry_id}_"): 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 return
entity_id = event.data["entity_id"] entity_id = event.data["entity_id"]
self.logger.info("Manual change detected on %s: %s → %s", self.logger.info("Manual change detected on %s: %s → %s", entity_id, old_state.state, new_state.state)
entity_id, old_state.state, new_state.state)
if new_state.state == "off" and old_state.state == "on": if new_state.state == "off" and old_state.state == "on":
if self._state in (STATE_ON, STATE_DELAYING, STATE_ACTIVE, STATE_MANUAL_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_timer("_off_delay_timer")
self._cancel_off_delay() self._cancel_timer("_manual_idle_timer")
self._stop_lux_check_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()) self.hass.async_create_task(self._enter_manual_off_cooldown())
return elif new_state.state == "on" and old_state.state == "off":
if new_state.state == "on" and old_state.state == "off":
if self._state in (STATE_IDLE, STATE_DETECTING, STATE_MANUAL_OFF_COOLDOWN): 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_timer("_motion_detect_timer")
self._cancel_motion_filter()
self._stop_lux_check_timer() self._stop_lux_check_timer()
self.hass.async_create_task(self._enter_manual_on()) self.hass.async_create_task(self._enter_manual_on())
return
@callback @callback
def _force_stop_state_changed(self, event): def _force_stop_state_changed(self, event):
"""Handle force stop sensor state change."""
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: 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()) self.hass.async_create_task(self._enter_force_stop())
# Если датчик вышел из блокирующего состояния
else: else:
self.logger.info("Force stop released by %s", event.data.get("entity_id"))
self.hass.async_create_task(self._exit_force_stop()) self.hass.async_create_task(self._exit_force_stop())
async def _enter_force_stop(self): async def _enter_force_stop(self):
"""Enter force_stopped state."""
self.logger.info("Entering force_stopped state")
self._cancel_all_timers() self._cancel_all_timers()
if self._state in (STATE_ON, STATE_DELAYING, STATE_ACTIVE, STATE_MANUAL_ON): if self._state in (STATE_ON, STATE_DELAYING, STATE_ACTIVE, STATE_MANUAL_ON):
await self._turn_off_switches() await self._turn_off_switches()
await self._update_state(STATE_FORCE_STOPPED) await self._update_state(STATE_FORCE_STOPPED)
async def _exit_force_stop(self): 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) 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: def _is_any_main_sensor_active(self) -> bool:
"""Check if any main sensor is currently in 'on' state.""" return self._is_any_sensor_from_list_active(self.main_sensors)
for sensor in self.main_sensors:
state = self.hass.states.get(sensor)
if state and state.state == "on":
return True
return False
def _is_any_extend_sensor_active(self) -> bool: def _is_any_extend_sensor_active(self) -> bool:
"""Check if any extend sensor is currently in 'on' state.""" return self._is_any_sensor_from_list_active(self.extend_sensors)
for sensor in self.extend_sensors:
state = self.hass.states.get(sensor)
if state and state.state == "on":
return True
return False
def _is_any_sensor_active(self) -> bool: 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() return self._is_any_main_sensor_active() or self._is_any_extend_sensor_active()
def _get_active_main_sensors(self) -> list: # --- Lux и таймеры ---
"""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"
]
def _get_current_lux(self) -> float | None: 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) state = self.hass.states.get(self.lux_sensor)
if not state or state.state in ("unknown", "unavailable"): if not state or state.state in ("unknown", "unavailable"): return None
return None try: return float(state.state)
try: except (ValueError, TypeError): return None
return float(state.state)
except (ValueError, TypeError):
return None
async def _should_turn_on_by_lux(self) -> bool: async def _should_turn_on_by_lux(self) -> bool:
"""Check if we should turn on based on lux.""" if not self.lux_sensor or self._lux_cooldown_active: return True
if not self.lux_sensor:
return True
if self._lux_cooldown_active:
self.logger.debug("Lux cooldown active, ignoring sensor")
return True
lux = self._get_current_lux() lux = self._get_current_lux()
if lux is None: return lux is None or lux < self.lux_threshold
return True
return lux < self.lux_threshold
def _start_lux_check_timer(self): def _start_lux_check_timer(self):
"""Start periodic lux check timer.""" if not self.lux_sensor or self._lux_check_timer: return
if not self.lux_sensor: self._lux_check_timer = async_call_later(self.hass, LUX_CHECK_INTERVAL, self._check_lux_periodically)
self.logger.debug("No lux sensor, not starting lux check timer") self.async_set_updated_data({"state": self._state, "attributes": self.attributes})
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,
)
self.async_set_updated_data({
"state": self._state,
"attributes": self.attributes,
})
def _stop_lux_check_timer(self): def _stop_lux_check_timer(self):
"""Stop periodic lux check timer."""
if self._lux_check_timer: if self._lux_check_timer:
self.logger.debug("Stopping lux check timer") self._cancel_timer("_lux_check_timer")
self._lux_check_timer() self.async_set_updated_data({"state": self._state, "attributes": self.attributes})
self._lux_check_timer = None
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")
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")
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")
await self._turn_on_switches()
await self._update_state(STATE_ON, triggered_by=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
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")
await self._turn_on_switches()
await self._update_state(STATE_ON, triggered_by=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
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()
def _start_lux_cooldown(self):
self._lux_cooldown_active = True self._lux_cooldown_active = True
if self.lux_cooldown > 0: 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 _start_lux_cooldown(self):
"""Start lux cooldown after turning off lights."""
self._lux_cooldown_active = True
if self.lux_cooldown > 0:
if self._lux_cooldown_timer:
self._lux_cooldown_timer()
self._lux_cooldown_timer = async_call_later( self._lux_cooldown_timer = async_call_later(
self.hass, self.hass,
self.lux_cooldown, self.lux_cooldown,
self._lux_cooldown_expired, self._lux_cooldown_expired,
) )
self.logger.info("Lux cooldown started for %d sec", self.lux_cooldown) self.logger.info("Lux cooldown started for %d sec", self.lux_cooldown)
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):
self._lux_check_timer = None
if not self._is_any_sensor_active() or self._state not in (STATE_IDLE, STATE_DETECTING):
return
lux = self._get_current_lux()
if lux is None:
self._start_lux_check_timer()
return
if lux < self.lux_threshold:
self.hass.async_create_task(self._turn_on_after_lux_check())
else:
self._start_lux_check_timer()
async def _turn_on_after_lux_check(self):
if self._lux_cooldown_active or self._state not in (STATE_IDLE, STATE_DETECTING) or not self._is_any_sensor_active():
return
self._cancel_lux_cooldown()
await self._turn_on_switches()
await self._update_state(STATE_ON, triggered_by=self._attributes.get("triggered_by"))
async def _turn_on_after_filter(self, _=None):
self._cancel_timer("_motion_detect_timer")
if not await self._should_turn_on_by_lux():
await self._update_state(STATE_IDLE)
self._start_lux_check_timer()
return
self._cancel_lux_cooldown()
await self._turn_on_switches()
await self._update_state(STATE_ON, triggered_by=self._attributes.get("triggered_by"))
async def _turn_off_after_delay(self, _=None):
self._cancel_timer("_off_delay_timer")
if self._is_any_sensor_active():
await self._update_state(STATE_ON)
return
await self._turn_off_switches()
self._start_lux_cooldown()
await self._update_state(STATE_OFF) await self._update_state(STATE_OFF)
async_call_later(self.hass, 1, self._transition_to_idle) async_call_later(self.hass, 1, self._transition_to_idle)
@callback @callback
def _transition_to_idle(self, _=None): def _transition_to_idle(self, _=None):
"""Transition from off to idle."""
if self._state == STATE_OFF: if self._state == STATE_OFF:
self.logger.debug("Transitioning from off to idle")
self.hass.async_create_task(self._update_state(STATE_IDLE)) self.hass.async_create_task(self._update_state(STATE_IDLE))
# --- Управление переключателями ---
async def _turn_on_switches(self) -> None: 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()}")
ctx = Context( await self.hass.services.async_call("homeassistant", "turn_on", {"entity_id": self.switch_entities}, context=ctx)
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,
)
async def _turn_off_switches(self) -> None: 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()}")
ctx = Context( await self.hass.services.async_call("homeassistant", "turn_off", {"entity_id": self.switch_entities}, context=ctx)
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,
)
# --- Ручное управление ---
async def _enter_manual_on(self): async def _enter_manual_on(self):
"""Enter manual_on state.""" self._cancel_lux_cooldown()
await self._update_state(STATE_MANUAL_ON) await self._update_state(STATE_MANUAL_ON)
if self.manual_idle_timeout > 0: if self.manual_idle_timeout > 0:
self._reset_manual_idle_timer() 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): def _reset_manual_idle_timer(self):
"""Reset manual idle timer.""" if self.manual_idle_timeout <= 0: return
if self.manual_idle_timeout <= 0: self._cancel_timer("_manual_idle_timer")
return self._manual_idle_timer = async_call_later(self.hass, self.manual_idle_timeout, self._manual_idle_timeout_expired)
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)
@callback @callback
def _manual_idle_timeout_expired(self, _=None): 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()) self.hass.async_create_task(self._turn_off_switches_manual())
async def _turn_off_switches_manual(self): async def _turn_off_switches_manual(self):
"""Turn off switches/lights in manual mode.""" await self.hass.services.async_call("homeassistant", "turn_off", {"entity_id": self.switch_entities})
self.logger.info("Turning OFF entities (manual timeout): %s", self.switch_entities) self._start_lux_cooldown()
await self.hass.services.async_call(
"homeassistant",
"turn_off",
{"entity_id": self.switch_entities},
)
await self._update_state(STATE_OFF) await self._update_state(STATE_OFF)
async_call_later(self.hass, 1, self._transition_to_idle) async_call_later(self.hass, 1, self._transition_to_idle)
async def _enter_manual_off_cooldown(self): async def _enter_manual_off_cooldown(self):
"""Enter manual_off_cooldown state.""" self._start_lux_cooldown()
await self._update_state(STATE_MANUAL_OFF_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: if self.manual_off_cooldown > 0:
self._manual_off_cooldown_timer = async_call_later( self._start_timer("_manual_off_cooldown_timer", self.manual_off_cooldown, self._manual_off_cooldown_expired)
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)
else: else:
await self._update_state(STATE_IDLE) await self._update_state(STATE_IDLE)
@callback @callback
def _manual_off_cooldown_expired(self, _=None): 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)) self.hass.async_create_task(self._update_state(STATE_IDLE))
@callback @callback
def _lux_cooldown_expired(self, _=None): def _lux_cooldown_expired(self, _=None):
"""Lux cooldown expired."""
self._lux_cooldown_active = False self._lux_cooldown_active = False
self._lux_cooldown_timer = None self._cancel_timer("_lux_cooldown_timer")
self.logger.info("Lux cooldown expired") self.async_set_updated_data({"state": self._state, "attributes": self.attributes})
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): def _cancel_all_timers(self):
"""Cancel all timers.""" self._cancel_timer("_motion_detect_timer")
if self._motion_detect_timer: self._cancel_timer("_off_delay_timer")
self._motion_detect_timer() self._cancel_timer("_lux_cooldown_timer")
self._motion_detect_timer = None self._cancel_timer("_lux_check_timer")
if self._off_delay_timer: self._cancel_timer("_manual_idle_timer")
self._off_delay_timer() self._cancel_timer("_manual_off_cooldown_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
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"domain": "motion_light", "domain": "motion_light",
"name": "Motion Light", "name": "Motion Light",
"version": "1.0.9",
"documentation": "https://github.com/sirmrak/motion_light",
"issue_tracker": "https://github.com/sirmrak/motion_light/issues",
"dependencies": [],
"codeowners": ["@sirmrak"], "codeowners": ["@sirmrak"],
"requirements": [], "config_flow": true,
"dependencies": [],
"documentation": "https://github.com/sirmrak/motion_light",
"iot_class": "local_push", "iot_class": "local_push",
"config_flow": true "issue_tracker": "https://github.com/sirmrak/motion_light/issues",
"requirements": [],
"version": "1.2.0-beta.3"
} }
@@ -58,22 +58,6 @@
"force_stopped": "Force stopped", "force_stopped": "Force stopped",
"error": "Error", "error": "Error",
"disabled": "Disabled" "disabled": "Disabled"
},
"attributes": {
"switch_entities": "Controlled switches",
"main_sensors": "Main triggers",
"extend_sensors": "Extend sensors",
"lux_sensor": "Illuminance sensor",
"force_stop_sensor": "Force stop sensor",
"force_stop_state": "Force stop state",
"force_stop_active": "Force stop active",
"active_main_sensors": "Active main triggers",
"active_extend_sensors": "Active extend sensors",
"lux_value": "Current illuminance",
"lux_cooldown_active": "Lux cooldown active",
"lux_check_active": "Lux check active",
"triggered_by": "Triggered by",
"last_motion_time": "Last motion time"
} }
} }
}, },
@@ -58,22 +58,6 @@
"force_stopped": "Принудительная остановка", "force_stopped": "Принудительная остановка",
"error": "Ошибка", "error": "Ошибка",
"disabled": "Отключено" "disabled": "Отключено"
},
"attributes": {
"switch_entities": "Управляемые выключатели",
"main_sensors": "Главные триггеры",
"extend_sensors": "Датчики продления",
"lux_sensor": "Датчик освещённости",
"force_stop_sensor": "Датчик принудительной остановки",
"force_stop_state": "Состояние принудительной остановки",
"force_stop_active": "Принудительная остановка активна",
"active_main_sensors": "Активные главные триггеры",
"active_extend_sensors": "Активные датчики продления",
"lux_value": "Текущая освещённость",
"lux_cooldown_active": "Cooldown люкса активен",
"lux_check_active": "Проверка люкса активна",
"triggered_by": "Активировано",
"last_motion_time": "Последнее движение"
} }
} }
}, },
+15
View File
@@ -7,10 +7,25 @@ version_variables = [
commit_message = "chore(release): v{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] [tool.semantic_release.branches.main]
match = "(main|master)" match = "(main|master)"
prerelease = false prerelease = false
# Настройки для beta релизов (ветка dev)
[tool.semantic_release.branches.dev]
match = "dev"
prerelease = true
prerelease_token = "beta"
[tool.semantic_release.commit_parser_options] [tool.semantic_release.commit_parser_options]
allowed_tags = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "revert"] allowed_tags = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "revert"]
minor_tags = ["feat"] minor_tags = ["feat"]