Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support to turn off lights that were manually turned on and minor bug fixes #92

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,11 @@ key | optional | type | default | description
`class` | False | string | AutoMoLi | The name of the Class.
`room` | False | string | | The "room" used to find matching sensors/light
`disable_switch_entities` | True | list/string | | One or more Home Assistant Entities as switch for AutoMoLi. If the state of **any** entity is *off*, AutoMoLi is *deactivated*. (Use an *input_boolean* for example)
`only_own_events` | True | bool | | Track if automoli switched this light on. If not, an existing timer will be deleted and the state will not change
`only_own_events` | True | bool | None | Track if automoli switched this light on. If not, automoli will not switch the light off. (see below)
`disable_switch_states` | True | list/string | ["off"] | Custom states for `disable_switch_entities`. If the state of **any** entity is *in this list*, AutoMoLi is *deactivated*. Can be used to disable with `media_players` in `playing` state for example.
`disable_hue_groups` | False | boolean | | Disable the use of Hue Groups/Scenes
`delay` | True | integer | 150 | Seconds without motion until lights will switched off. Can be disabled (lights stay always on) with `0`
`delay_outside_events` | True | integer | same as delay | Seconds without motion until lights will switched off, if they were turned on by an event outside automoli (e.g., manually, via automation, etc.). Can be disabled (lights stay always on) with `0`
~~`motion_event`~~ | ~~True~~ | ~~string~~ | | **replaced by `motion_state_on/off`**
`daytimes` | True | list | *see code* | Different daytimes with light settings (see below)
`transition_on_daytime_switch` | True | bool | False | directly activate a daytime on its start time (instead to just set it as active daytime used if lights are switched from off to on)
Expand All @@ -150,8 +151,8 @@ key | optional | type | default | description
`illuminance_threshold` | True | integer | | If illuminance is *above* this value, lights will *not switched on*
`humidity` | True | list/string | | Humidity sensor entities
`humidity_threshold` | True | integer | | If humidity is *above* this value, lights will *not switched off*
`motion_state_on` | True | integer | | If using motion sensors which don't send events if already activated, like Xiaomi do, add this to your config with "on". This will listen to state changes instead
`motion_state_off` | True | integer | | If using motion sensors which don't send events if already activated, like Xiaomi do, add this to your config with "off". This will listen to the state changes instead.
`motion_state_on` | True | integer | | If using motion sensors which don't send events if already activated, like Xiaomi do with the Xiaomi Gateway (Aqara) integration, add this to your config with "on". This will listen to state changes instead
`motion_state_off` | True | integer | | If using motion sensors which don't send events if already activated, like Xiaomi do with the Xiaomi Gateway (Aqara) integration, add this to your config with "off". This will listen to the state changes instead
`debug_log` | True | bool | false | Activate debug logging (for this room)

### daytimes
Expand All @@ -163,6 +164,16 @@ key | optional | type | default | description
`delay` | True | integer | 150 | Seconds without motion until lights will switched off. Can be disabled (lights stay always on) with `0`. Setting this will overwrite the global `delay` setting for this daytime.
`light` | False | integer/string | | Light setting (percent integer value (0-100) in or scene entity

Note: If there is only one daytime, the light and delay settings will be applied for the entire day, regardless of the starttime.

### only_own_events

state | description
-- | --
None | Lights will be turned off after motion is detected, regardless of whether AutoMoLi turned the lights on.
False | Lights will be turned off after motion is detected, regardless of whether AutoMoLi turned the lights on AND after the delay if they were turned on outside AutoMoLi (e.g., manually or via an automation).
True | Lights will only be turned off after motion is detected, if AutoMoLi turned the lights on.

---

<!-- ## Used by
Expand Down
156 changes: 119 additions & 37 deletions apps/automoli/automoli.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def lg(
except AttributeError:
ha_name = APP_NAME
self.lg(
"No room set yet, using 'AutoMoLi' forlogging to HA",
"No room set yet, using 'AutoMoLi' for logging to HA",
level=logging.DEBUG,
)

Expand Down Expand Up @@ -245,6 +245,11 @@ async def initialize(self) -> None:
# general delay
self.delay = int(self.args.pop("delay", DEFAULT_DELAY))

# delay for events outside AutoMoLi, defaults to same as general delay
self.delay_outside_events = int(
self.args.pop("delay_outside_events", self.delay)
)

# directly switch to new daytime light settings
self.transition_on_daytime_switch: bool = bool(
self.args.pop("transition_on_daytime_switch", False)
Expand Down Expand Up @@ -303,7 +308,13 @@ async def initialize(self) -> None:
)

# store if an entity has been switched on by automoli
self.only_own_events: bool = bool(self.args.pop("only_own_events", False))
# None: automoli will turn off lights after motion detected
# (regardless of whether automoli turned light on originally;
# treated differently from False to support legacy behavior)
# False: automoli will turn off lights after motion detected OR delay
# (regardless of whether automoli turned light on originally)
# True: automoli will only turn off lights it turned on
self.only_own_events: bool = self.args.pop("only_own_events", None)
self._switched_on_by_automoli: set[str] = set()

self.disable_hue_groups: bool = self.args.pop("disable_hue_groups", False)
Expand Down Expand Up @@ -364,8 +375,12 @@ async def initialize(self) -> None:
appdaemon=self.get_ad_api(),
)

# requirements check
if not self.lights or not self.sensors[EntityType.MOTION.idx]:
# requirements check:
# - lights must exist
# - motion must exist or only using automoli to turn off lights manually turned on after delay
if not self.lights or not (
self.sensors[EntityType.MOTION.idx] or self.only_own_events == False
):
self.lg("")
self.lg(
f"{hl('No lights/sensors')} given and none found with name: "
Expand Down Expand Up @@ -439,11 +454,29 @@ async def initialize(self) -> None:
new=self.states["motion_off"],
)
)
# set up state listener for each light, if tracking events outside automoli
if self.only_own_events == False:
self.lg(
"handling events outside AutoMoLi - adding state listeners for lights",
level=logging.DEBUG,
)
for light in self.lights:
listener.add(
self.listen_state(
self.outside_change_detected,
entity_id=light,
new="on",
)
)
# assume any lights that are currently on were switched on by AutoMoLi
if await self.get_state(light) == "on":
self._switched_on_by_automoli.add(light)

self.args.update(
{
"room": self.room_name.capitalize(),
"delay": self.delay,
"delay_outside_events": self.delay_outside_events,
"active_daytime": self.active_daytime,
"daytimes": daytimes,
"lights": self.lights,
Expand Down Expand Up @@ -564,7 +597,7 @@ async def motion_detected(

# calling motion event handler
data: dict[str, Any] = {"entity_id": entity, "new": new, "old": old}
await self.motion_event("state_changed_detection", data, kwargs)
await self.motion_event("motion_state_changed_detection", data, kwargs)

async def motion_event(
self, event: str, data: dict[str, str], _: dict[str, Any]
Expand All @@ -585,8 +618,8 @@ async def motion_event(
if await self.is_disabled():
return

# turn on the lights if not already
if self.dimming or not any(
# turn on the lights if not all are already on
if self.dimming or not all(
[await self.get_state(light) == "on" for light in self.lights]
):
self.lg(
Expand All @@ -595,15 +628,45 @@ async def motion_event(
)
await self.lights_on()
else:
refresh = ""
if event != "motion_state_changed_detection":
refresh = " → refreshing timer"
self.lg(
f"{stack()[0][3]}: light in {self.room.name.capitalize()} already on → refreshing "
f"timer | {self.dimming = }",
f"{stack()[0][3]}: lights in {self.room.name.capitalize()} already on {refresh}"
f" | {self.dimming = }",
level=logging.DEBUG,
)

if event != "state_changed_detection":
if event != "motion_state_changed_detection":
await self.refresh_timer()

async def outside_change_detected(
self, entity: str, attribute: str, old: str, new: str, kwargs: dict[str, Any]
) -> None:
"""wrapper for when listening to outside light changes. on `state_changed` callback
of a light setup a timer by calling `refresh_timer`
"""
# ensure the change wasn't because of automoli
if entity in self._switched_on_by_automoli:
return

self.lg(
f"{stack()[0][3]}: {entity} changed {attribute} from {old} to {new}",
level=logging.DEBUG,
)

# cancel scheduled callbacks
await self.clear_handles()

self.lg(
f"{stack()[0][3]}: handles cleared and cancelled all scheduled timers"
f" | {self.dimming = }",
level=logging.DEBUG,
)

# setting timers
await self.refresh_timer(outside_change=True)

def has_min_ad_version(self, required_version: str) -> bool:
required_version = required_version if required_version else "4.0.7"
return bool(
Expand All @@ -630,7 +693,7 @@ async def clear_handles(self, handles: set[str] = None) -> None:

self.lg(f"{stack()[0][3]}: cancelled scheduled callbacks", level=logging.DEBUG)

async def refresh_timer(self) -> None:
async def refresh_timer(self, outside_change=False) -> None:
"""refresh delay timer."""

fnn = f"{stack()[0][3]}:"
Expand All @@ -643,11 +706,17 @@ async def refresh_timer(self) -> None:
# cancel scheduled callbacks
await self.clear_handles()

# if an external event (e.g., switch turned on manually) was detected use delay_outside_events
if outside_change:
delay = self.delay_outside_events
else:
delay = self.active.get("delay")

# if no delay is set or delay = 0, lights will not switched off by AutoMoLi
if delay := self.active.get("delay"):
if delay:

self.lg(
f"{fnn} {self.active = } | {delay = } | {self.dim = }",
f"{fnn} {self.active = } | {self.delay_outside_events = } | {outside_change = } | {delay = } | {self.dim = }",
level=logging.DEBUG,
)

Expand All @@ -658,17 +727,22 @@ async def refresh_timer(self) -> None:
handle = await self.run_in(self.dim_lights, (dim_in_sec))

else:
handle = await self.run_in(self.lights_off, delay)
handle = await self.run_in(self.lights_off, delay, timeDelay=delay)

self.room.handles_automoli.add(handle)

if timer_info := await self.info_timer(handle):
self.lg(
f"{fnn} scheduled callback to switch off the lights in {dim_in_sec}s "
f"{fnn} scheduled callback to switch off the lights in {dim_in_sec}s at"
f"({timer_info[0].isoformat()}) | "
f"handles: {self.room.handles_automoli = }",
level=logging.DEBUG,
)
else:
self.lg(
"No delay was set or delay = 0, lights will not be switched off by AutoMoLi",
level=logging.DEBUG,
)

async def night_mode_active(self) -> bool:
return bool(
Expand Down Expand Up @@ -864,8 +938,8 @@ async def lights_on(self, force: bool = False) -> None:

if isinstance(light_setting, str):

# last check until we switch the lights on... really!
if not force and any(
# last check until we switch all the lights on... really!
if not force and all(
[await self.get_state(light) == "on" for light in self.lights]
):
self.lg("¯\\_(ツ)_/¯")
Expand All @@ -881,17 +955,15 @@ async def lights_on(self, force: bool = False) -> None:
group_name=await self.friendly_name(entity), # type:ignore
scene_name=light_setting, # type:ignore
)
if self.only_own_events:
self._switched_on_by_automoli.add(entity)
self._switched_on_by_automoli.add(entity)
continue

item = light_setting if light_setting.startswith("scene.") else entity

await self.call_service(
"homeassistant/turn_on", entity_id=item # type:ignore
) # type:ignore
if self.only_own_events:
self._switched_on_by_automoli.add(item)
self._switched_on_by_automoli.add(item)

self.lg(
f"{hl(self.room.name.capitalize())} turned {hl('on')} → "
Expand All @@ -907,8 +979,8 @@ async def lights_on(self, force: bool = False) -> None:
await self.lights_off({})

else:
# last check until we switch the lights on... really!
if not force and any(
# last check until we switch all the lights on... really!
if not force and all(
[await self.get_state(light) == "on" for light in self.lights]
):
self.lg("¯\\_(ツ)_/¯")
Expand All @@ -926,14 +998,13 @@ async def lights_on(self, force: bool = False) -> None:
brightness_pct=light_setting, # type:ignore
)

self.lg(
f"{hl(self.room.name.capitalize())} turned {hl('on')} → "
f"brightness: {hl(light_setting)}%"
f" | delay: {hl(natural_time(int(self.active['delay'])))}",
icon=ON_ICON,
)
if self.only_own_events:
self._switched_on_by_automoli.add(entity)
self.lg(
f"{hl(self.room.name.capitalize())} turned {hl('on')} → "
f"brightness: {hl(light_setting)}%"
f" | delay: {hl(natural_time(int(self.active['delay'])))}",
icon=ON_ICON,
)
self._switched_on_by_automoli.add(entity)

else:
raise ValueError(
Expand All @@ -944,7 +1015,7 @@ async def lights_off(self, _: dict[str, Any]) -> None:
"""Turn off the lights."""

self.lg(
f"{stack()[0][3]} {await self.is_disabled()} | {await self.is_blocked() = }",
f"{stack()[0][3]}: {await self.is_disabled() = } | {await self.is_blocked() = }",
level=logging.DEBUG,
)

Expand Down Expand Up @@ -979,9 +1050,13 @@ async def lights_off(self, _: dict[str, Any]) -> None:
await self.call_service(
"homeassistant/turn_off", entity_id=entity # type:ignore
) # type:ignore
if entity in self._switched_on_by_automoli:
self._switched_on_by_automoli.remove(entity)
at_least_one_turned_off = True
if at_least_one_turned_off:
self.run_in_thread(self.turned_off, thread=self.notify_thread)
self.run_in_thread(
self.turned_off, thread=self.notify_thread, timeDelay=_.get("timeDelay")
)

# experimental | reset for xiaomi "super motion" sensors | idea from @wernerhp
# app: https://github.com/wernerhp/appdaemon_aqara_motion_sensors
Expand All @@ -1000,9 +1075,13 @@ async def turned_off(self, _: dict[str, Any] | None = None) -> None:
# cancel scheduled callbacks
await self.clear_handles()

delay = (
self.active["delay"] if _.get("timeDelay") is None else _.get("timeDelay")
)

self.lg(
f"no motion in {hl(self.room.name.capitalize())} since "
f"{hl(natural_time(int(self.active['delay'])))} → turned {hl('off')}",
f"{hl(natural_time(int(delay)))} → turned {hl('off')}",
icon=OFF_ICON,
)

Expand Down Expand Up @@ -1131,8 +1210,11 @@ async def build_daytimes(

starttimes.add(dt_start)

# check if this daytime should ne active now
if await self.now_is_between(str(dt_start), str(next_dt_start)):
# check if this daytime should be active now (and default true if only 1 daytime is provided)
if (
await self.now_is_between(str(dt_start), str(next_dt_start))
or len(daytimes) == 1
):
await self.switch_daytime(dict(daytime=daytime, initial=True))
self.active_daytime = daytime.get("daytime")

Expand Down Expand Up @@ -1223,7 +1305,7 @@ def _print_cfg_setting(self, key: str, value: int | str, indentation: int) -> No
indent = indentation * " "

# legacy way
if key == "delay" and isinstance(value, int):
if (key == "delay" or key == "delay_outside_events") and isinstance(value, int):
unit = "min"
min_value = f"{int(value / 60)}:{int(value % 60):02d}"
self.lg(
Expand Down