Compare commits

...
27 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
10 changed files with 437 additions and 723 deletions
+3 -26
View File
@@ -5,14 +5,13 @@ 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' if: github.actor != 'gitea-actions[bot]' && github.actor != 'Gitea Actions Bot'
permissions: permissions:
contents: write contents: write
@@ -41,29 +40,7 @@ jobs:
env: env:
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }} GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
run: | run: |
echo "🧹 Сбрасываем конфликтующие переменные Gitea Actions..."
unset GITHUB_SERVER_URL unset GITHUB_SERVER_URL
unset GITHUB_API_URL unset GITHUB_API_URL
unset GITHUB_REPOSITORY unset GITHUB_REPOSITORY
semantic-release version
echo "🔐 Записываем оригинальный Root CA от mkcert из секретов..."
echo "${{ secrets.LOCAL_ROOT_CA }}" > /tmp/rootCA.crt
# Проверка на случай, если секрет забыли настроить
if [ ! -s /tmp/rootCA.crt ]; then
echo "❌ Ошибка: Секрет LOCAL_ROOT_CA пуст. Проверьте настройки репозитория!"
exit 1
fi
echo "✅ Root CA успешно прочитан."
# Находим, где конкретно Python (библиотека certifi) хранит свои сертификаты
CERTIFI_PATH=$(python3 -c "import certifi; print(certifi.where())")
echo "Путь к bundle Python: $CERTIFI_PATH"
# Внедряем корневой сертификат mkcert в конец доверенного списка Python
cat /tmp/rootCA.crt >> "$CERTIFI_PATH"
echo "✅ Root CA от mkcert добавлен в доверенные для Python!"
echo "🚀 Запускаем semantic-release..."
semantic-release version
+21 -8
View File
@@ -4,10 +4,9 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch: # Позволяет запускать вручную workflow_dispatch:
jobs: jobs:
# Шаг 1: Валидация структуры HA интеграции
validate: validate:
name: Validate Integration name: Validate Integration
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -23,7 +22,6 @@ jobs:
with: with:
category: "integration" category: "integration"
# Шаг 2: Запуск тестов (если есть)
test: test:
name: Run Tests name: Run Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -51,7 +49,6 @@ jobs:
echo "No tests directory found, skipping tests" echo "No tests directory found, skipping tests"
fi fi
# Шаг 3: Проверка стиля кода
lint: lint:
name: Code Quality name: Code Quality
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -77,7 +74,6 @@ jobs:
run: | run: |
ruff check custom_components/motion_light/ || echo "Ruff issues found" ruff check custom_components/motion_light/ || echo "Ruff issues found"
# Шаг 4: Создание релиза (только если все проверки пройдены)
release: release:
name: Create Release name: Create Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -97,6 +93,15 @@ jobs:
TAG=${GITHUB_REF#refs/tags/} TAG=${GITHUB_REF#refs/tags/}
echo "tag=$TAG" >> $GITHUB_OUTPUT 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 "") PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then if [ -z "$PREV_TAG" ]; then
@@ -115,18 +120,26 @@ jobs:
run: | run: |
TAG="${{ steps.tag.outputs.tag }}" TAG="${{ steps.tag.outputs.tag }}"
COMMITS="${{ steps.tag.outputs.commits }}" 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 cat > /tmp/release_notes.md << EOF
## Изменения в версии $TAG ## $TITLE
$COMMITS $COMMITS
EOF EOF
gh release create "$TAG" \ gh release create "$TAG" \
--title "Release $TAG" \ --title "$TITLE" \
--notes-file /tmp/release_notes.md \ --notes-file /tmp/release_notes.md \
--target main --target main \
${IS_BETA:+--prerelease}
echo "✅ Релиз создан!" echo "✅ Релиз создан!"
+59
View File
@@ -2,6 +2,65 @@
<!-- version list --> <!-- 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) ## v1.0.13 (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
+156 -487
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,9 +131,8 @@ 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:
self.motion_filter = int(value) self.motion_filter = int(value)
@@ -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,327 @@ 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({ def _start_lux_cooldown(self):
"state": self._state, self._lux_cooldown_active = True
"attributes": self.attributes, 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 @callback
def _check_lux_periodically(self, _=None): def _check_lux_periodically(self, _=None):
"""Check lux periodically."""
self._lux_check_timer = None self._lux_check_timer = None
if not self._is_any_sensor_active(): if not self._is_any_sensor_active() or self._state not in (STATE_IDLE, STATE_DETECTING):
self.logger.debug("No sensors active, not checking lux")
return 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() lux = self._get_current_lux()
if lux is None: if lux is None:
self.logger.debug("Lux value unavailable, restarting timer")
self._start_lux_check_timer() self._start_lux_check_timer()
return return
self.logger.debug("[Lux check] lux=%.1f, threshold=%d", lux, self.lux_threshold)
if 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()) self.hass.async_create_task(self._turn_on_after_lux_check())
else: else:
self.logger.debug("[Lux check] Still bright, restarting timer")
self._start_lux_check_timer() self._start_lux_check_timer()
async def _turn_on_after_lux_check(self): async def _turn_on_after_lux_check(self):
"""Turn on lights after periodic lux check.""" if self._lux_cooldown_active or self._state not in (STATE_IDLE, STATE_DETECTING) or not self._is_any_sensor_active():
if self._lux_cooldown_active:
self.logger.debug("Lux cooldown active, not turning on")
return return
if self._state not in (STATE_IDLE, STATE_DETECTING): self._cancel_lux_cooldown()
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._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): async def _turn_on_after_filter(self, _=None):
"""Turn on after motion filter.""" self._cancel_timer("_motion_detect_timer")
self.logger.debug("Turning on after filter")
self._motion_detect_timer = None
if not await self._should_turn_on_by_lux(): 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) await self._update_state(STATE_IDLE)
self._start_lux_check_timer() self._start_lux_check_timer()
return return
self._cancel_lux_cooldown()
triggered_by = self._attributes.get("triggered_by")
await self._turn_on_switches() 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): async def _turn_off_after_delay(self, _=None):
"""Turn off after off-delay.""" self._cancel_timer("_off_delay_timer")
self.logger.debug("Off-delay timer expired")
self._off_delay_timer = None
if self._is_any_sensor_active(): if self._is_any_sensor_active():
self.logger.info("Sensor is still active — staying on")
await self._update_state(STATE_ON) await self._update_state(STATE_ON)
return return
self.logger.info("No sensors active, turning off")
await self._turn_off_switches() await self._turn_off_switches()
await self._start_lux_cooldown()
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._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) await 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.""" await 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
+10 -10
View File
@@ -1,12 +1,12 @@
{ {
"domain": "motion_light", "domain": "motion_light",
"name": "Motion Light", "name": "Motion Light",
"version": "1.0.13", "codeowners": ["@sirmrak"],
"documentation": "https://github.com/sirmrak/motion_light", "config_flow": true,
"issue_tracker": "https://github.com/sirmrak/motion_light/issues", "dependencies": [],
"dependencies": [], "documentation": "https://github.com/sirmrak/motion_light",
"codeowners": ["@sirmrak"], "iot_class": "local_push",
"requirements": [], "issue_tracker": "https://github.com/sirmrak/motion_light/issues",
"iot_class": "local_push", "requirements": [],
"config_flow": true "version": "1.2.0-beta.2"
} }
@@ -1,101 +1,85 @@
{ {
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Motion Light", "title": "Motion Light",
"description": "Motion-based lighting control setup", "description": "Motion-based lighting control setup",
"data": { "data": {
"name": "Name", "name": "Name",
"main_sensors": "Main triggers", "main_sensors": "Main triggers",
"extend_sensors": "Extend sensors (optional)", "extend_sensors": "Extend sensors (optional)",
"switch_entities": "Controlled switches", "switch_entities": "Controlled switches",
"lux_sensor": "Illuminance sensor (optional)", "lux_sensor": "Illuminance sensor (optional)",
"force_stop_sensor": "Force stop sensor (optional)", "force_stop_sensor": "Force stop sensor (optional)",
"force_stop_state": "Force stop sensor state", "force_stop_state": "Force stop sensor state",
"log_level": "Log level" "log_level": "Log level"
}
}
} }
}
}
},
"options": {
"step": {
"init": {
"title": "Motion Light Options",
"description": "Change integration parameters",
"data": {
"main_sensors": "Main triggers",
"extend_sensors": "Extend sensors (optional)",
"switch_entities": "Controlled switches",
"lux_sensor": "Illuminance sensor (optional)",
"force_stop_sensor": "Force stop sensor (optional)",
"force_stop_state": "Force stop sensor state",
"log_level": "Log level"
}
}
}
},
"entity": {
"switch": {
"motion_light_enabled": {
"name": "Control"
},
"motion_light_priority_manual": {
"name": "Manual priority"
}
}, },
"sensor": { "options": {
"motion_light_status": { "step": {
"name": "Status", "init": {
"state": { "title": "Motion Light Options",
"idle": "Idle", "description": "Change integration parameters",
"detecting": "Detecting", "data": {
"on": "On", "main_sensors": "Main triggers",
"delaying": "Delaying", "extend_sensors": "Extend sensors (optional)",
"active": "Extended", "switch_entities": "Controlled switches",
"off": "Off", "lux_sensor": "Illuminance sensor (optional)",
"manual_on": "Manual on", "force_stop_sensor": "Force stop sensor (optional)",
"manual_off_cooldown": "Manual off cooldown", "force_stop_state": "Force stop sensor state",
"force_stopped": "Force stopped", "log_level": "Log level"
"error": "Error", }
"disabled": "Disabled" }
}
},
"entity": {
"switch": {
"motion_light_enabled": {
"name": "Control"
},
"motion_light_priority_manual": {
"name": "Manual priority"
}
}, },
"attributes": { "sensor": {
"switch_entities": "Controlled switches", "motion_light_status": {
"main_sensors": "Main triggers", "name": "Status",
"extend_sensors": "Extend sensors", "state": {
"lux_sensor": "Illuminance sensor", "idle": "Idle",
"force_stop_sensor": "Force stop sensor", "detecting": "Detecting",
"force_stop_state": "Force stop state", "on": "On",
"force_stop_active": "Force stop active", "delaying": "Delaying",
"active_main_sensors": "Active main triggers", "active": "Extended",
"active_extend_sensors": "Active extend sensors", "off": "Off",
"lux_value": "Current illuminance", "manual_on": "Manual on",
"lux_cooldown_active": "Lux cooldown active", "manual_off_cooldown": "Manual off cooldown",
"lux_check_active": "Lux check active", "force_stopped": "Force stopped",
"triggered_by": "Triggered by", "error": "Error",
"last_motion_time": "Last motion time" "disabled": "Disabled"
}
}
},
"number": {
"motion_light_motion_filter": {
"name": "Motion filter"
},
"motion_light_off_delay": {
"name": "Off delay"
},
"motion_light_lux_threshold": {
"name": "Lux threshold"
},
"motion_light_lux_cooldown": {
"name": "Lux cooldown"
},
"motion_light_manual_idle_timeout": {
"name": "'Forgot to turn off' timer"
},
"motion_light_manual_off_cooldown": {
"name": "Manual off cooldown"
}
} }
}
},
"number": {
"motion_light_motion_filter": {
"name": "Motion filter"
},
"motion_light_off_delay": {
"name": "Off delay"
},
"motion_light_lux_threshold": {
"name": "Lux threshold"
},
"motion_light_lux_cooldown": {
"name": "Lux cooldown"
},
"motion_light_manual_idle_timeout": {
"name": "'Forgot to turn off' timer"
},
"motion_light_manual_off_cooldown": {
"name": "Manual off cooldown"
}
} }
}
} }
@@ -1,101 +1,85 @@
{ {
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Motion Light", "title": "Motion Light",
"description": "Настройка управления освещением по движению", "description": "Настройка управления освещением по движению",
"data": { "data": {
"name": "Название", "name": "Название",
"main_sensors": "Главные триггеры", "main_sensors": "Главные триггеры",
"extend_sensors": "Датчики продления (опционально)", "extend_sensors": "Датчики продления (опционально)",
"switch_entities": "Управляемые выключатели", "switch_entities": "Управляемые выключатели",
"lux_sensor": "Датчик освещённости (опционально)", "lux_sensor": "Датчик освещённости (опционально)",
"force_stop_sensor": "Датчик принудительной остановки (опционально)", "force_stop_sensor": "Датчик принудительной остановки (опционально)",
"force_stop_state": "Состояние датчика принудительной остановки", "force_stop_state": "Состояние датчика принудительной остановки",
"log_level": "Уровень логирования" "log_level": "Уровень логирования"
}
}
} }
}
}
},
"options": {
"step": {
"init": {
"title": "Настройки Motion Light",
"description": "Изменение параметров интеграции",
"data": {
"main_sensors": "Главные триггеры",
"extend_sensors": "Датчики продления (опционально)",
"switch_entities": "Управляемые выключатели",
"lux_sensor": "Датчик освещённости (опционально)",
"force_stop_sensor": "Датчик принудительной остановки (опционально)",
"force_stop_state": "Состояние датчика принудительной остановки",
"log_level": "Уровень логирования"
}
}
}
},
"entity": {
"switch": {
"motion_light_enabled": {
"name": "Управление"
},
"motion_light_priority_manual": {
"name": "Приоритет ручного"
}
}, },
"sensor": { "options": {
"motion_light_status": { "step": {
"name": "Статус", "init": {
"state": { "title": "Настройки Motion Light",
"idle": "Ожидание", "description": "Изменение параметров интеграции",
"detecting": "Обнаружение", "data": {
"on": "Включено", "main_sensors": "Главные триггеры",
"delaying": "Задержка выключения", "extend_sensors": "Датчики продления (опционально)",
"active": "Продлено", "switch_entities": "Управляемые выключатели",
"off": "Выключено", "lux_sensor": "Датчик освещённости (опционально)",
"manual_on": "Ручное включение", "force_stop_sensor": "Датчик принудительной остановки (опционально)",
"manual_off_cooldown": "Пауза после ручного выкл.", "force_stop_state": "Состояние датчика принудительной остановки",
"force_stopped": "Принудительная остановка", "log_level": "Уровень логирования"
"error": "Ошибка", }
"disabled": "Отключено" }
}
},
"entity": {
"switch": {
"motion_light_enabled": {
"name": "Управление"
},
"motion_light_priority_manual": {
"name": "Приоритет ручного"
}
}, },
"attributes": { "sensor": {
"switch_entities": "Управляемые выключатели", "motion_light_status": {
"main_sensors": "Главные триггеры", "name": "Статус",
"extend_sensors": "Датчики продления", "state": {
"lux_sensor": "Датчик освещённости", "idle": "Ожидание",
"force_stop_sensor": "Датчик принудительной остановки", "detecting": "Обнаружение",
"force_stop_state": "Состояние принудительной остановки", "on": "Включено",
"force_stop_active": "Принудительная остановка активна", "delaying": "Задержка выключения",
"active_main_sensors": "Активные главные триггеры", "active": "Продлено",
"active_extend_sensors": "Активные датчики продления", "off": "Выключено",
"lux_value": "Текущая освещённость", "manual_on": "Ручное включение",
"lux_cooldown_active": "Cooldown люкса активен", "manual_off_cooldown": "Пауза после ручного выкл.",
"lux_check_active": "Проверка люкса активна", "force_stopped": "Принудительная остановка",
"triggered_by": "Активировано", "error": "Ошибка",
"last_motion_time": "Последнее движение" "disabled": "Отключено"
}
}
},
"number": {
"motion_light_motion_filter": {
"name": "Фильтр движения"
},
"motion_light_off_delay": {
"name": "Задержка выключения"
},
"motion_light_lux_threshold": {
"name": "Порог освещённости"
},
"motion_light_lux_cooldown": {
"name": "Cooldown люкса"
},
"motion_light_manual_idle_timeout": {
"name": "Таймер 'забыл выключить'"
},
"motion_light_manual_off_cooldown": {
"name": "Cooldown ручного выкл."
}
} }
}
},
"number": {
"motion_light_motion_filter": {
"name": "Фильтр движения"
},
"motion_light_off_delay": {
"name": "Задержка выключения"
},
"motion_light_lux_threshold": {
"name": "Порог освещённости"
},
"motion_light_lux_cooldown": {
"name": "Cooldown люкса"
},
"motion_light_manual_idle_timeout": {
"name": "Таймер 'забыл выключить'"
},
"motion_light_manual_off_cooldown": {
"name": "Cooldown ручного выкл."
}
} }
}
} }
+10 -3
View File
@@ -7,18 +7,25 @@ version_variables = [
commit_message = "chore(release): v{version}" commit_message = "chore(release): v{version}"
# ВАЖНО: не создаём релиз в Gitea (релиз будет в GitHub) # Отключаем создание релиза через API Gitea (релиз будет создаваться в GitHub)
upload_to_vcs_release = false upload_to_vcs_release = false
[tool.semantic_release.remote] [tool.semantic_release.remote]
type = "gitea" type = "gitea"
domain = "192.168.200.208:3000" domain = "git.darkmode.duckdns.org"
api_domain = "192.168.200.208:3000" 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"]