New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
+446
View File
@@ -0,0 +1,446 @@
"""The Scheduler Integration."""
import logging
import voluptuous as vol
import datetime
import homeassistant.util.dt as dt_util
from homeassistant.helpers import config_validation as cv
from homeassistant.components.switch import DOMAIN as PLATFORM
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
ATTR_ENTITY_ID,
ATTR_NAME,
)
from homeassistant.core import HomeAssistant, asyncio, CoreState, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_registry import async_get as get_entity_registry
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.event import (
async_call_later,
async_track_state_change_event,
async_track_point_in_time,
)
from . import const
from .store import async_get_registry
from .websockets import async_register_websockets
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass, config):
"""Track states and offer events for sensors."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Scheduler integration from a config entry."""
session = async_get_clientsession(hass)
store = await async_get_registry(hass)
coordinator = SchedulerCoordinator(hass, session, entry, store)
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(const.DOMAIN, coordinator.id)},
name="Scheduler",
model="Scheduler",
sw_version=const.VERSION,
manufacturer="@nielsfaber",
)
hass.data.setdefault(const.DOMAIN, {})
hass.data[const.DOMAIN] = {"coordinator": coordinator, "schedules": {}}
if entry.unique_id is None:
hass.config_entries.async_update_entry(entry, unique_id=coordinator.id)
await hass.config_entries.async_forward_entry_setups(entry, [PLATFORM])
await async_register_websockets(hass)
@callback
def service_create_schedule(service):
coordinator.async_create_schedule(dict(service.data))
hass.services.async_register(
const.DOMAIN,
const.SERVICE_ADD,
service_create_schedule,
schema=const.ADD_SCHEDULE_SCHEMA,
)
@callback
def async_service_edit_schedule(service):
match = None
for (schedule_id, entity) in hass.data[const.DOMAIN]["schedules"].items():
if entity.entity_id == service.data[const.ATTR_ENTITY_ID]:
match = schedule_id
continue
if not match:
raise vol.Invalid(
"Entity not found: {}".format(service.data[const.ATTR_ENTITY_ID])
)
else:
data = dict(service.data)
del data[const.ATTR_ENTITY_ID]
coordinator.async_edit_schedule(match, data)
hass.services.async_register(
const.DOMAIN,
const.SERVICE_EDIT,
async_service_edit_schedule,
schema=const.EDIT_SCHEDULE_SCHEMA.extend(
{vol.Required(ATTR_ENTITY_ID): cv.string}
),
)
@callback
def async_service_remove_schedule(service):
match = None
for (schedule_id, entity) in hass.data[const.DOMAIN]["schedules"].items():
if entity.entity_id == service.data["entity_id"]:
match = schedule_id
continue
if not match:
raise vol.Invalid("Entity not found: {}".format(service.data["entity_id"]))
else:
coordinator.async_delete_schedule(match)
hass.services.async_register(
const.DOMAIN,
const.SERVICE_REMOVE,
async_service_remove_schedule,
schema=vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.string}),
)
@callback
def service_copy_schedule(service):
match = None
for (schedule_id, entity) in hass.data[const.DOMAIN]["schedules"].items():
if entity.entity_id == service.data[const.ATTR_ENTITY_ID]:
match = schedule_id
continue
if not match:
raise vol.Invalid(
"Entity not found: {}".format(service.data[const.ATTR_ENTITY_ID])
)
else:
data = store.async_get_schedule(match)
tags = coordinator.async_get_tags_for_schedule(data[const.ATTR_SCHEDULE_ID])
if tags:
data[const.ATTR_TAGS] = tags
del data[const.ATTR_SCHEDULE_ID]
if ATTR_NAME in service.data:
data[ATTR_NAME] = service.data[ATTR_NAME].strip()
coordinator.async_create_schedule(data)
hass.services.async_register(
const.DOMAIN,
const.SERVICE_COPY,
service_copy_schedule,
schema=vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.string,
vol.Optional(ATTR_NAME): vol.Any(cv.string, None),
}
),
)
async def async_service_disable_all(service):
await coordinator.async_disable_all_schedules()
hass.services.async_register(
const.DOMAIN,
const.SERVICE_DISABLE_ALL,
async_service_disable_all
)
async def async_service_enable_all(service):
await coordinator.async_enable_all_schedules()
hass.services.async_register(
const.DOMAIN,
const.SERVICE_ENABLE_ALL,
async_service_enable_all
)
return True
async def async_migrate_entry(hass, config_entry: ConfigEntry):
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1:
config_entry.version = 2
config_entry.data = {"migrate_entities": True}
return True
async def async_unload_entry(hass, entry):
"""Unload Scheduler config entry."""
unload_ok = all(
await asyncio.gather(
*[hass.config_entries.async_forward_entry_unload(entry, PLATFORM)]
)
)
coordinator = hass.data[const.DOMAIN]["coordinator"]
await coordinator.async_unload()
return unload_ok
async def async_remove_entry(hass, entry):
"""Remove Scheduler data."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
await coordinator.async_delete_config()
del hass.data[const.DOMAIN]
class SchedulerCoordinator(DataUpdateCoordinator):
"""Define an object to hold scheduler data."""
def __init__(self, hass, session, entry, store):
"""Initialize."""
self.id = entry.unique_id
self.hass = hass
self.store = store
self.state = const.STATE_INIT
self._workday_tracker = None
self._workday_timer = None
self.stopped = False
super().__init__(hass, _LOGGER, name=const.DOMAIN)
# detect time of prior shutdown to determine which schedules need to be triggered
time_shutdown = self.store.async_get_time_shutdown()
if time_shutdown:
self.time_shutdown = dt_util.as_local(datetime.datetime.fromisoformat(time_shutdown))
_LOGGER.debug("Scheduler detected a shutdown at {}.".format(self.time_shutdown))
else:
self.time_shutdown = None
# wait for 10 seconds after HA startup to allow entities to be initialized
@callback
def handle_startup(_event):
hass.async_create_task(self.async_init_workday_sensor())
@callback
def async_timer_finished(_now):
self.state = const.STATE_READY
async_dispatcher_send(self.hass, const.EVENT_STARTED)
async_call_later(hass, 10, async_timer_finished)
if hass.state == CoreState.running:
handle_startup(None)
else:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, handle_startup)
# store the current date+time when scheduler is being shutdown
@callback
async def async_handle_shutdown(_event):
if self.stopped:
return
now = dt_util.utcnow().isoformat()
await self.store.async_set_time_shutdown(now)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_handle_shutdown)
def async_get_schedule(self, schedule_id: str):
"""fetch a schedule (websocket API hook)"""
if schedule_id not in self.hass.data[const.DOMAIN]["schedules"]:
return None
item = self.hass.data[const.DOMAIN]["schedules"][schedule_id]
return item.async_get_entity_state()
def async_get_schedules(self):
"""fetch a list of schedules (websocket API hook)"""
schedules = self.hass.data[const.DOMAIN]["schedules"]
data = []
for item in schedules.values():
config = item.async_get_entity_state()
data.append(config)
return data
@callback
def async_create_schedule(self, data):
"""add a new schedule"""
tags = None
if const.ATTR_TAGS in data:
tags = data[const.ATTR_TAGS]
del data[const.ATTR_TAGS]
res = self.store.async_create_schedule(data)
if res:
self.async_assign_tags_to_schedule(res.schedule_id, tags)
async_dispatcher_send(self.hass, const.EVENT_ITEM_CREATED, res)
@callback
def async_edit_schedule(self, schedule_id: str, data: dict):
"""edit an existing schedule"""
if schedule_id not in self.hass.data[const.DOMAIN]["schedules"]:
return
item = self.async_get_schedule(schedule_id)
if ATTR_NAME in data and item[ATTR_NAME] != data[ATTR_NAME]:
data[ATTR_NAME] = data[ATTR_NAME].strip()
elif ATTR_NAME in data:
del data[ATTR_NAME]
tags_updated = False
tags = None
if const.ATTR_TAGS in data:
tags_updated = True
tags = data[const.ATTR_TAGS]
del data[const.ATTR_TAGS]
entry = self.store.async_update_schedule(schedule_id, data)
if tags_updated:
self.async_assign_tags_to_schedule(schedule_id, tags)
entity = self.hass.data[const.DOMAIN]["schedules"][schedule_id]
if ATTR_NAME in data:
# if the name has been changed, the entity ID must change hence the entity should be destroyed
entity_registry = get_entity_registry(self.hass)
entity_registry.async_remove(entity.entity_id)
async_dispatcher_send(self.hass, const.EVENT_ITEM_CREATED, entry)
else:
async_dispatcher_send(self.hass, const.EVENT_ITEM_UPDATED, schedule_id)
@callback
def async_delete_schedule(self, schedule_id: str):
"""delete an existing schedule"""
if schedule_id not in self.hass.data[const.DOMAIN]["schedules"]:
return
entity = self.hass.data[const.DOMAIN]["schedules"][schedule_id]
entity_registry = get_entity_registry(self.hass)
entity_registry.async_remove(entity.entity_id)
self.store.async_delete_schedule(schedule_id)
self.async_assign_tags_to_schedule(schedule_id, None)
self.hass.data[const.DOMAIN]["schedules"].pop(schedule_id, None)
async_dispatcher_send(self.hass, const.EVENT_ITEM_REMOVED, schedule_id)
async def _async_update_data(self):
"""Update data via library."""
return True
async def async_unload(self):
if self._workday_tracker:
self._workday_tracker()
self._workday_tracker = None
self.stopped = True
async def async_delete_config(self):
await self.store.async_delete()
def async_get_tags(self):
"""fetch a list of tags (websocket API hook)"""
tags = self.store.async_get_tags()
return list(tags.values())
def async_get_tags_for_schedule(self, schedule_id: str):
"""fetch a list of tags for a schedule"""
tags = self.async_get_tags()
result = filter(lambda el: schedule_id in el[const.ATTR_SCHEDULES], tags)
result = list(map(lambda x: x[ATTR_NAME], result))
result = sorted(result)
return result
def async_assign_tags_to_schedule(self, schedule_id: str, new_tags: list):
if not new_tags:
new_tags = []
old_tags = self.async_get_tags_for_schedule(schedule_id)
for tag_name in old_tags:
if tag_name not in new_tags:
# remove old tag
el = self.store.async_get_tag(tag_name)
if len(el[const.ATTR_SCHEDULES]) > 1:
self.store.async_update_tag(
tag_name,
{
const.ATTR_SCHEDULES: [
x for x in el[const.ATTR_SCHEDULES] if x != schedule_id
]
},
)
else:
self.store.async_delete_tag(tag_name)
else:
new_tags.remove(tag_name)
for tag_name in new_tags:
# assign new tag
el = self.store.async_get_tag(tag_name)
if el:
self.store.async_update_tag(
tag_name,
{const.ATTR_SCHEDULES: el[const.ATTR_SCHEDULES] + [schedule_id]},
)
else:
self.store.async_create_tag(
{ATTR_NAME: tag_name, const.ATTR_SCHEDULES: [schedule_id]}
)
async def async_reset_workday_timer(self):
"""the workday polling timer has finished"""
@callback
async def async_workday_timer_finished(_now):
"""perform daily polling of the workday entity"""
_LOGGER.debug("Performing daily update of workday sensor")
await self.async_reset_workday_timer()
async_dispatcher_send(self.hass, const.EVENT_WORKDAY_SENSOR_UPDATED)
now = dt_util.as_local(dt_util.utcnow())
ts = dt_util.find_next_time_expression_time(
now, seconds=[0], minutes=[5], hours=[0]
)
today = now.date()
while ts.date() == today:
# ensure the timer is set for the next day
now = now + datetime.timedelta(days=1)
ts = dt_util.find_next_time_expression_time(
now, seconds=[0], minutes=[5], hours=[0]
)
if self._workday_timer:
self._workday_timer()
self._workday_timer = async_track_point_in_time(
self.hass, async_workday_timer_finished, ts
)
async def async_init_workday_sensor(self):
"""watch for changes in the workday sensor"""
workday_entity = self.hass.states.get(const.WORKDAY_ENTITY)
if not workday_entity:
return None
@callback
async def async_workday_state_updated(_event):
"""the workday sensor has been updated"""
_LOGGER.debug("Workday sensor has updated")
await self.async_reset_workday_timer()
async_dispatcher_send(self.hass, const.EVENT_WORKDAY_SENSOR_UPDATED)
self._workday_tracker = async_track_state_change_event(
self.hass, const.WORKDAY_ENTITY, async_workday_state_updated
)
await self.async_reset_workday_timer()
async def async_enable_all_schedules(self):
"""enables all schedules"""
for schedule in self.hass.data[const.DOMAIN]["schedules"].values():
await schedule.async_turn_on()
async def async_disable_all_schedules(self):
"""disables all schedules"""
for schedule in self.hass.data[const.DOMAIN]["schedules"].values():
await schedule.async_turn_off()
+629
View File
@@ -0,0 +1,629 @@
import logging
from homeassistant.core import (
HomeAssistant,
callback,
CoreState,
)
from homeassistant.const import (
CONF_SERVICE,
ATTR_SERVICE_DATA,
CONF_SERVICE_DATA,
CONF_DELAY,
ATTR_ENTITY_ID,
STATE_UNKNOWN,
STATE_UNAVAILABLE,
CONF_CONDITIONS,
CONF_ATTRIBUTE,
CONF_STATE,
CONF_ACTION
)
from homeassistant.components.climate import (
SERVICE_SET_TEMPERATURE,
SERVICE_SET_HVAC_MODE,
ATTR_HVAC_MODE,
ATTR_TEMPERATURE,
ATTR_TARGET_TEMP_LOW,
ATTR_TARGET_TEMP_HIGH,
DOMAIN as CLIMATE_DOMAIN,
)
from homeassistant.helpers.event import (
async_track_state_change_event,
async_call_later,
)
from homeassistant.helpers.service import async_call_from_config
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from . import const
from .store import ScheduleEntry
_LOGGER = logging.getLogger(__name__)
ACTION_WAIT = "wait"
ACTION_WAIT_STATE_CHANGE = "wait_state_change"
def parse_service_call(data: dict):
"""turn action data into a service call"""
service_call = {
CONF_ACTION: data[CONF_ACTION] if CONF_ACTION in data else data[CONF_SERVICE], # map service->action for backwards compaibility
CONF_SERVICE_DATA: data[ATTR_SERVICE_DATA],
}
if ATTR_ENTITY_ID in data and data[ATTR_ENTITY_ID]:
service_call[ATTR_ENTITY_ID] = data[ATTR_ENTITY_ID]
if (
service_call[CONF_ACTION]
== "{}.{}".format(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE)
and ATTR_HVAC_MODE in service_call[CONF_SERVICE_DATA]
and ATTR_ENTITY_ID in service_call
):
# fix for climate integrations which don't support setting hvac_mode and temperature together
# add small delay between service calls for integrations that have a long processing time
# set temperature setpoint again for integrations which lose setpoint after switching hvac_mode
_service_call = [
{
CONF_ACTION: "{}.{}".format(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE),
ATTR_ENTITY_ID: service_call[ATTR_ENTITY_ID],
CONF_SERVICE_DATA: {
ATTR_HVAC_MODE: service_call[CONF_SERVICE_DATA][ATTR_HVAC_MODE]
},
}
]
if (
ATTR_TEMPERATURE in service_call[CONF_SERVICE_DATA]
or ATTR_TARGET_TEMP_LOW in service_call[CONF_SERVICE_DATA]
or ATTR_TARGET_TEMP_HIGH in service_call[CONF_SERVICE_DATA]
):
_service_call.extend([
{
CONF_ACTION: ACTION_WAIT_STATE_CHANGE,
ATTR_ENTITY_ID: service_call[ATTR_ENTITY_ID],
CONF_SERVICE_DATA: {
CONF_DELAY: 50,
CONF_STATE: service_call[CONF_SERVICE_DATA][ATTR_HVAC_MODE]
},
},
{
CONF_ACTION: "{}.{}".format(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE),
ATTR_ENTITY_ID: service_call[ATTR_ENTITY_ID],
CONF_SERVICE_DATA: {
x: service_call[CONF_SERVICE_DATA][x]
for x in service_call[CONF_SERVICE_DATA]
if x != ATTR_HVAC_MODE
},
},
])
return _service_call
else:
return [service_call]
def entity_is_available(hass: HomeAssistant, entity, is_target_entity=False):
"""evaluate whether an entity is ready for targeting"""
state = hass.states.get(entity)
if state is None:
return False
elif state.state == STATE_UNAVAILABLE:
return False
elif state.state != STATE_UNKNOWN:
return True
elif is_target_entity:
# only reject unknown state when scheduler is initializing
coordinator = hass.data["scheduler"]["coordinator"]
if coordinator.state == const.STATE_INIT:
return False
else:
return True
else:
# for condition entities the unknown state is not allowed
return False
def action_is_available(hass: HomeAssistant, action: str):
"""evaluate whether a HA action is ready for targeting"""
if action in [ACTION_WAIT, ACTION_WAIT_STATE_CHANGE]:
return True
domain = action.split(".").pop(0)
domain_service = action.split(".").pop(1)
return hass.services.has_service(domain, domain_service)
def validate_condition(hass: HomeAssistant, condition: dict, *args):
"""Validate a condition against the current state"""
if not entity_is_available(hass, condition[ATTR_ENTITY_ID], True):
return False
state = hass.states.get(condition[ATTR_ENTITY_ID])
required = condition[const.ATTR_VALUE]
actual = state.state if state else None
if len(args):
actual = args[0]
if (
condition[const.ATTR_MATCH_TYPE]
in [
const.MATCH_TYPE_BELOW,
const.MATCH_TYPE_ABOVE,
]
and isinstance(required, str)
):
# parse condition as numeric if should be smaller or larger than X
required = float(required)
if isinstance(required, int):
try:
actual = int(float(actual))
except (ValueError, TypeError):
return False
elif isinstance(required, float):
try:
actual = float(actual)
except (ValueError, TypeError):
return False
elif isinstance(required, str):
actual = str(actual).lower()
required = required.lower()
if condition[const.ATTR_MATCH_TYPE] == const.MATCH_TYPE_EQUAL:
result = actual == required
elif condition[const.ATTR_MATCH_TYPE] == const.MATCH_TYPE_UNEQUAL:
result = actual != required
elif condition[const.ATTR_MATCH_TYPE] == const.MATCH_TYPE_BELOW:
result = actual < required
elif condition[const.ATTR_MATCH_TYPE] == const.MATCH_TYPE_ABOVE:
result = actual > required
else:
result = False
# _LOGGER.debug(
# "validating condition for {}: required={}, actual={}, match_type={}, result={}"
# .format(condition[ATTR_ENTITY_ID], required, actual, condition[const.ATTR_MATCH_TYPE], result)
# )
return result
def action_has_effect(action: dict, hass: HomeAssistant):
"""check if action has an effect on the entity"""
if ATTR_ENTITY_ID not in action:
return True
domain = action[CONF_ACTION].split(".").pop(0)
service = action[CONF_ACTION].split(".").pop(1)
state = hass.states.get(action[ATTR_ENTITY_ID])
current_state = state.state if state else None
if (
domain == CLIMATE_DOMAIN
and service in [SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE]
and state
):
if (
ATTR_HVAC_MODE in action[CONF_SERVICE_DATA]
and action[CONF_SERVICE_DATA][ATTR_HVAC_MODE] != current_state
):
return True
if ATTR_TEMPERATURE in action[CONF_SERVICE_DATA] and float(
state.attributes.get(ATTR_TEMPERATURE, 0) or 0
) != float(action[CONF_SERVICE_DATA].get(ATTR_TEMPERATURE)):
return True
if ATTR_TARGET_TEMP_LOW in action[CONF_SERVICE_DATA] and float(
state.attributes.get(ATTR_TARGET_TEMP_LOW, 0) or 0
) != float(action[CONF_SERVICE_DATA].get(ATTR_TARGET_TEMP_LOW)):
return True
if ATTR_TARGET_TEMP_HIGH in action[CONF_SERVICE_DATA] and float(
state.attributes.get(ATTR_TARGET_TEMP_HIGH, 0) or 0
) != float(action[CONF_SERVICE_DATA].get(ATTR_TARGET_TEMP_HIGH)):
return True
return False
return True
class ActionHandler:
def __init__(self, hass: HomeAssistant, schedule_id: str):
"""init"""
self.hass = hass
self._queues = {}
self._timer = None
self.id = schedule_id
async_dispatcher_connect(
self.hass, "action_queue_finished", self.async_cleanup_queues
)
async def async_queue_actions(self, data: ScheduleEntry, skip_initial_execution = False):
"""add new actions to queue"""
await self.async_empty_queue()
conditions = data[CONF_CONDITIONS]
actions = [e for x in data[const.ATTR_ACTIONS] for e in parse_service_call(x)]
condition_type = data[const.ATTR_CONDITION_TYPE]
track_conditions = data[const.ATTR_TRACK_CONDITIONS]
# create an ActionQueue object per targeted entity (such that the tasks are handled independently)
for action in actions:
entity = action[ATTR_ENTITY_ID] if ATTR_ENTITY_ID in action else "none"
if entity not in self._queues:
self._queues[entity] = ActionQueue(
self.hass, self.id, conditions, condition_type, track_conditions
)
self._queues[entity].add_action(action)
for queue in self._queues.copy().values():
await queue.async_start(skip_initial_execution)
async def async_cleanup_queues(self, id: str = None):
"""remove all objects from queue which have no remaining tasks"""
if id is not None and id != self.id or not len(self._queues.keys()):
return
# remove all items which are either finished executing
# or have all their entities available (i.e. conditions have failed beforee)
queue_items = list(self._queues.keys())
for key in queue_items:
if self._queues[key].is_finished() or (
self._queues[key].is_available() and not self._queues[key].queue_busy
):
await self._queues[key].async_clear()
self._queues.pop(key)
if not len(self._queues.keys()):
_LOGGER.debug("[{}]: Finished execution of tasks".format(self.id))
async def async_empty_queue(self, **kwargs):
"""remove all objects from queue"""
restore_time = kwargs.get("restore_time")
async def async_clear_queue(_now=None):
"""clear queue"""
if self._timer:
self._timer()
self._timer = None
while len(self._queues.keys()):
key = list(self._queues.keys())[0]
await self._queues[key].async_clear()
self._queues.pop(key)
if restore_time:
await self.async_cleanup_queues()
if not len(self._queues):
return
_LOGGER.debug(
"Waiting for unavailable entities to be restored for {} mins".format(
restore_time
)
)
self._timer = async_call_later(
self.hass, restore_time * 60, async_clear_queue
)
else:
await async_clear_queue()
class ActionQueue:
def __init__(
self,
hass: HomeAssistant,
id: str,
conditions: list,
condition_type: str,
track_conditions: bool,
):
"""create a new action queue"""
self.hass = hass
self.id = id
self._timer = None
self._action_entities = []
self._condition_entities = []
self._listeners = []
self._state_update_listener = None
self._conditions = conditions
self._condition_type = condition_type
self._queue = []
self.queue_busy = False
self._track_conditions = track_conditions
self._wait_for_available = True
for condition in conditions:
if (
ATTR_ENTITY_ID in condition
and condition[ATTR_ENTITY_ID] not in self._condition_entities
):
self._condition_entities.append(condition[ATTR_ENTITY_ID])
def add_action(self, action: dict):
"""add an action to the queue"""
if (
ATTR_ENTITY_ID in action
and action[ATTR_ENTITY_ID]
and action[ATTR_ENTITY_ID] not in self._action_entities
):
self._action_entities.append(action[ATTR_ENTITY_ID])
self._queue.append(action)
async def async_start(self, skip_initial_execution):
"""start execution of the actions in the queue"""
@callback
async def async_entity_changed(event):
"""check if actions can be processed"""
entity = event.data["entity_id"]
old_state = event.data["old_state"].state if event.data["old_state"] else None
new_state = event.data["new_state"].state if event.data["new_state"] else None
if old_state == new_state:
# no change
return
if self.queue_busy:
return
if entity not in self._condition_entities and not self._wait_for_available:
# only watch until entity becomes available in the action entities
return
if (
entity in self._condition_entities
and old_state
and new_state
and old_state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]
and new_state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]
):
conditions = list(filter(lambda e: e[ATTR_ENTITY_ID] == entity, self._conditions))
if all([
validate_condition(self.hass, item, old_state) == validate_condition(self.hass, item, new_state)
for item in conditions
]):
# ignore if state change has no effect on condition rules
return
_LOGGER.debug(
"[{}]: State of {} has changed, re-evaluating actions".format(
self.id, entity
)
)
await self.async_process_queue()
watched_entities = list(set(self._condition_entities + self._action_entities))
if len(watched_entities):
self._listeners.append(
async_track_state_change_event(
self.hass, watched_entities, async_entity_changed
)
)
if not skip_initial_execution:
await self.async_process_queue()
# trigger the queue once when HA has restarted
if self.hass.state != CoreState.running:
self._listeners.append(
async_dispatcher_connect(
self.hass, const.EVENT_STARTED, self.async_process_queue
)
)
else:
self._wait_for_available = False
async def async_clear(self):
"""clear action queue object"""
if self._timer:
self._timer()
self._timer = None
while len(self._listeners):
self._listeners.pop()()
if self._state_update_listener:
self._state_update_listener()
self._state_update_listener = None
def is_finished(self):
"""check whether all queue items are finished"""
return len(self._queue) == 0
def is_available(self):
"""check if all actions and entities involved in the task are available"""
# check actions
required_actions = [action[CONF_ACTION] for action in self._queue]
failed_action = next(
(x for x in required_actions if not action_is_available(self.hass, x)),
None,
)
if failed_action:
_LOGGER.debug(
"[{}]: Action {} is unavailable, scheduled task cannot be executed".format(
self.id, failed_action
)
)
return False
# check entities
watched_entities = list(set(self._condition_entities + self._action_entities))
failed_entity = next(
(
x
for x in watched_entities
if not entity_is_available(self.hass, x, x in self._action_entities)
),
None,
)
if failed_entity:
_LOGGER.debug(
"[{}]: Entity {} is unavailable, scheduled action cannot be executed".format(
self.id, failed_entity
)
)
return False
if self._wait_for_available:
self._wait_for_available = False
return True
async def async_process_queue(self, task_idx=0):
"""walk through the list of tasks and execute the ones that are ready"""
if self.queue_busy or not self.is_available():
return
self.queue_busy = True
# verify conditions
conditions_passed = (
(
all(validate_condition(self.hass, item) for item in self._conditions)
if self._condition_type == const.CONDITION_TYPE_AND
else any(
validate_condition(self.hass, item) for item in self._conditions
)
)
if len(self._conditions)
else True
)
if not conditions_passed and len(self._queue):
_LOGGER.debug(
"[{}]: Conditions have failed, skipping execution of actions".format(
self.id
)
)
if self._track_conditions:
# postpone tasks
self.queue_busy = False
return
else:
# abort all items in queue
while len(self._queue):
self._queue.pop()
skip_task = False
while task_idx < len(self._queue):
task = self._queue[task_idx]
if task[CONF_ACTION] in [ACTION_WAIT, ACTION_WAIT_STATE_CHANGE]:
if skip_action:
task_idx = task_idx + 1
continue
elif task[CONF_ACTION] == ACTION_WAIT_STATE_CHANGE:
state = self.hass.states.get(task[ATTR_ENTITY_ID])
if CONF_ATTRIBUTE in task[CONF_SERVICE_DATA]:
state = state.attributes.get(task[CONF_SERVICE_DATA][CONF_ATTRIBUTE])
else:
state = state.state
if state == task[CONF_SERVICE_DATA][CONF_STATE]:
_LOGGER.debug(
"[{}]: Entity {} is already set to {}, proceed with next task".format(
self.id,
task[ATTR_ENTITY_ID],
state,
)
)
task_idx = task_idx + 1
continue
@callback
async def async_timer_finished(_now):
self._timer = None
if self._state_update_listener:
self._state_update_listener()
self._state_update_listener = None
self.queue_busy = False
await self.async_process_queue(task_idx + 1)
self._timer = async_call_later(
self.hass,
task[CONF_SERVICE_DATA][CONF_DELAY],
async_timer_finished,
)
_LOGGER.debug(
"[{}]: Postponing next task for {} seconds".format(
self.id, task[CONF_SERVICE_DATA][CONF_DELAY]
)
)
@callback
async def async_entity_changed(event):
entity = event.data["entity_id"]
old_state = event.data["old_state"]
new_state = event.data["new_state"]
if CONF_ATTRIBUTE in task[CONF_SERVICE_DATA]:
old_state = old_state.attributes.get(task[CONF_SERVICE_DATA][CONF_ATTRIBUTE])
new_state = new_state.attributes.get(task[CONF_SERVICE_DATA][CONF_ATTRIBUTE])
else:
old_state = old_state.state
new_state = new_state.state
if old_state == new_state:
return
_LOGGER.debug(
"[{}]: Entity {} was updated from {} to {}".format(
self.id,
entity,
old_state,
new_state
)
)
if new_state == task[CONF_SERVICE_DATA][CONF_STATE]:
_LOGGER.debug("[{}]: Stop postponing next task".format(self.id))
if self._timer:
self._timer()
self._timer = None
self._state_update_listener()
self._state_update_listener = None
self.queue_busy = False
await self.async_process_queue(task_idx + 1)
if task[CONF_ACTION] == ACTION_WAIT_STATE_CHANGE:
self._state_update_listener = async_track_state_change_event(
self.hass, task[ATTR_ENTITY_ID], async_entity_changed
)
return
if ATTR_ENTITY_ID in task:
_LOGGER.debug(
"[{}]: Executing action {} on entity {}".format(
self.id, task[CONF_ACTION], task[ATTR_ENTITY_ID]
)
)
else:
_LOGGER.debug(
"[{}]: Executing action {}".format(self.id, task[CONF_ACTION])
)
skip_action = not action_has_effect(task, self.hass)
if skip_action:
_LOGGER.debug("[{}]: Action has no effect, skipping".format(self.id))
else:
await async_call_from_config(
self.hass,
task,
)
task_idx = task_idx + 1
self.queue_busy = False
if not self._track_conditions or not len(self._conditions):
while len(self._queue):
self._queue.pop()
async_dispatcher_send(self.hass, "action_queue_finished", self.id)
else:
_LOGGER.debug(
"[{}]: Done for now, Waiting for conditions to change".format(self.id)
)
@@ -0,0 +1,26 @@
"""Config flow for the Scheduler component."""
import secrets
from homeassistant import config_entries
from . import const
class SchedulerConfigFlow(config_entries.ConfigFlow, domain=const.DOMAIN):
"""Config flow for Scheduler."""
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
# Only a single instance of the integration
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
id = secrets.token_hex(6)
await self.async_set_unique_id(id)
self._abort_if_unique_id_configured(updates=user_input)
return self.async_create_entry(title="Scheduler", data={})
+220
View File
@@ -0,0 +1,220 @@
"""Store constants."""
import voluptuous as vol
import re
import homeassistant.util.dt as dt_util
from homeassistant.helpers import config_validation as cv
from homeassistant.const import (
WEEKDAYS,
ATTR_ENTITY_ID,
SUN_EVENT_SUNRISE,
SUN_EVENT_SUNSET,
ATTR_SERVICE,
ATTR_SERVICE_DATA,
CONF_CONDITIONS,
CONF_ATTRIBUTE,
ATTR_NAME,
)
VERSION = "3.3.8"
DOMAIN = "scheduler"
SUN_ENTITY = "sun.sun"
DAY_TYPE_DAILY = "daily"
DAY_TYPE_WORKDAY = "workday"
DAY_TYPE_WEEKEND = "weekend"
WORKDAY_ENTITY = "binary_sensor.workday_sensor"
ATTR_SKIP_CONDITIONS = "skip_conditions"
ATTR_CONDITION_TYPE = "condition_type"
CONDITION_TYPE_AND = "and"
CONDITION_TYPE_OR = "or"
ATTR_MATCH_TYPE = "match_type"
MATCH_TYPE_EQUAL = "is"
MATCH_TYPE_UNEQUAL = "not"
MATCH_TYPE_BELOW = "below"
MATCH_TYPE_ABOVE = "above"
ATTR_TRACK_CONDITIONS = "track_conditions"
ATTR_REPEAT_TYPE = "repeat_type"
REPEAT_TYPE_REPEAT = "repeat"
REPEAT_TYPE_SINGLE = "single"
REPEAT_TYPE_PAUSE = "pause"
EVENT = "scheduler_updated"
SERVICE_REMOVE = "remove"
SERVICE_EDIT = "edit"
SERVICE_ADD = "add"
SERVICE_COPY = "copy"
SERVICE_DISABLE_ALL = "disable_all"
SERVICE_ENABLE_ALL = "enable_all"
OffsetTimePattern = re.compile(r"^([a-z]+)([-|\+]{1})([0-9:]+)$")
DatePattern = re.compile(r"^[0-9]+\-[0-9]+\-[0-9]+$")
ATTR_START = "start"
ATTR_STOP = "stop"
ATTR_TIMESLOTS = "timeslots"
ATTR_WEEKDAYS = "weekdays"
ATTR_ENABLED = "enabled"
ATTR_SCHEDULE_ID = "schedule_id"
ATTR_ACTIONS = "actions"
ATTR_VALUE = "value"
ATTR_TAGS = "tags"
ATTR_SCHEDULES = "schedules"
ATTR_START_DATE = "start_date"
ATTR_END_DATE = "end_date"
EVENT_TIMER_FINISHED = "scheduler_timer_finished"
EVENT_TIMER_UPDATED = "scheduler_timer_updated"
EVENT_ITEM_UPDATED = "scheduler_item_updated"
EVENT_ITEM_CREATED = "scheduler_item_created"
EVENT_ITEM_REMOVED = "scheduler_item_removed"
EVENT_STARTED = "scheduler_started"
EVENT_WORKDAY_SENSOR_UPDATED = "workday_sensor_updated"
STATE_INIT = "init"
STATE_READY = "ready"
STATE_COMPLETED = "completed"
def validate_time(time):
res = OffsetTimePattern.match(time)
if not res:
if dt_util.parse_time(time):
return time
else:
raise vol.Invalid("Invalid time entered: {}".format(time))
else:
if res.group(1) not in [SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET]:
raise vol.Invalid("Invalid time entered: {}".format(time))
elif res.group(2) not in ["+", "-"]:
raise vol.Invalid("Invalid time entered: {}".format(time))
elif not dt_util.parse_time(res.group(3)):
raise vol.Invalid("Invalid time entered: {}".format(time))
else:
return time
def validate_date(value: str) -> str:
"""Input must be either none or a valid date."""
if value is None:
return None
date = dt_util.parse_date(value)
if date is None:
raise vol.Invalid("Invalid date entered: {}".format(value))
else:
return date.strftime("%Y-%m-%d")
CONDITION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_VALUE): vol.Any(int, float, str),
vol.Optional(CONF_ATTRIBUTE): cv.string,
vol.Required(ATTR_MATCH_TYPE): vol.In(
[MATCH_TYPE_EQUAL, MATCH_TYPE_UNEQUAL, MATCH_TYPE_BELOW, MATCH_TYPE_ABOVE]
),
}
)
ACTION_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_SERVICE): cv.entity_id,
vol.Optional(ATTR_SERVICE_DATA): dict,
}
)
TIMESLOT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_START): validate_time,
vol.Optional(ATTR_STOP): validate_time,
vol.Optional(CONF_CONDITIONS): vol.All(
cv.ensure_list, vol.Length(min=1), [CONDITION_SCHEMA]
),
vol.Optional(ATTR_CONDITION_TYPE): vol.In(
[
CONDITION_TYPE_AND,
CONDITION_TYPE_OR,
]
),
vol.Optional(ATTR_TRACK_CONDITIONS): cv.boolean,
vol.Required(ATTR_ACTIONS): vol.All(
cv.ensure_list, vol.Length(min=1), [ACTION_SCHEMA]
),
}
)
ADD_SCHEDULE_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_WEEKDAYS, default=[DAY_TYPE_DAILY]): vol.All(
cv.ensure_list,
vol.Unique(),
vol.Length(min=1),
[
vol.In(
WEEKDAYS
+ [
DAY_TYPE_WORKDAY,
DAY_TYPE_WEEKEND,
DAY_TYPE_DAILY,
]
)
],
),
vol.Optional(ATTR_START_DATE, default=None): validate_date,
vol.Optional(ATTR_END_DATE, default=None): validate_date,
vol.Required(ATTR_TIMESLOTS): vol.All(
cv.ensure_list, vol.Length(min=1), [TIMESLOT_SCHEMA]
),
vol.Required(ATTR_REPEAT_TYPE): vol.In(
[
REPEAT_TYPE_REPEAT,
REPEAT_TYPE_SINGLE,
REPEAT_TYPE_PAUSE,
]
),
vol.Optional(ATTR_NAME): vol.Any(cv.string, None),
vol.Optional(ATTR_TAGS): vol.All(cv.ensure_list, vol.Unique(), [cv.string]),
}
)
EDIT_SCHEDULE_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_WEEKDAYS): vol.All(
cv.ensure_list,
vol.Unique(),
vol.Length(min=1),
[
vol.In(
WEEKDAYS
+ [
DAY_TYPE_WORKDAY,
DAY_TYPE_WEEKEND,
DAY_TYPE_DAILY,
]
)
],
),
vol.Optional(ATTR_START_DATE, default=None): validate_date,
vol.Optional(ATTR_END_DATE, default=None): validate_date,
vol.Optional(ATTR_TIMESLOTS): vol.All(
cv.ensure_list, vol.Length(min=1), [TIMESLOT_SCHEMA]
),
vol.Optional(ATTR_REPEAT_TYPE): vol.In(
[
REPEAT_TYPE_REPEAT,
REPEAT_TYPE_SINGLE,
REPEAT_TYPE_PAUSE,
]
),
vol.Optional(ATTR_NAME): vol.Any(cv.string, None),
vol.Optional(ATTR_TAGS): vol.All(cv.ensure_list, vol.Unique(), [cv.string]),
}
)
+12
View File
@@ -0,0 +1,12 @@
{
"domain": "scheduler",
"name": "Scheduler",
"codeowners": ["@nielsfaber"],
"config_flow": true,
"dependencies": ["http", "websocket_api"],
"documentation": "https://github.com/nielsfaber/scheduler-component",
"iot_class": "local_push",
"issue_tracker": "https://github.com/nielsfaber/scheduler-component/issues",
"requirements": [],
"version": "v0.0.0"
}
+180
View File
@@ -0,0 +1,180 @@
run_action:
name: Run Action
description: Execute the action of a schedule, optionally at a given time.
fields:
entity_id:
name: Entity
description: Identifier of the scheduler entity.
example: "switch.schedule_abcdef"
required: true
selector:
entity:
integration: scheduler
domain: switch
time:
name: Time
description: Time for which to evaluate the action (only useful for schedules with multiple timeslot)
example: '"12:00"'
required: false
selector:
time:
skip_conditions:
name: Skip Conditions
description: Whether the conditions of the schedule should be skipped or not
required: false
selector:
boolean:
remove:
name: Remove
description: Remove a schedule entity
fields:
entity_id:
name: Entity
description: Identifier of the scheduler entity.
example: "switch.schedule_abcdef"
required: true
selector:
entity:
integration: scheduler
domain: switch
add:
name: Add
description: Create a new schedule entity
fields:
weekdays:
name: Weekdays
description: Days of the week for which the schedule should be repeated
example: '["daily"]'
required: false
selector:
object:
start_date:
name: Start date
description: Date from which schedule should be executed
example: '["2021-01-01"]'
required: false
selector:
object:
end_date:
name: End date
description: Date until which schedule should be executed
example: '["2021-12-31"]'
required: false
selector:
object:
timeslots:
name: Timeslots
description: list of timeslots with their actions and optionally conditions (should be kept the same for all timeslots)
example: '[{start: "12:00", stop: "13:00", actions: [{service: "light.turn_on", entity_id: "light.my_lamp", service_data: {brightness: 200}}]}]'
required: true
selector:
object:
repeat_type:
name: Repeat Type
description: Control what happens after the schedule is triggered
example: '"repeat"'
required: true
selector:
select:
options:
- repeat
- single
- pause
name:
name: Name
description: Friendly name for the schedule
required: false
example: "My schedule"
selector:
text:
edit:
name: Edit
description: Edit a schedule entity
fields:
entity_id:
name: Entity
description: Identifier of the scheduler entity.
example: "switch.schedule_abcdef"
required: true
selector:
entity:
integration: scheduler
domain: switch
weekdays:
name: Weekdays
description: Days of the week for which the schedule should be repeated
example: '["daily"]'
required: false
selector:
object:
start_date:
name: Start date
description: Date from which schedule should be executed
example: '["2021-01-01"]'
required: false
selector:
object:
end_date:
name: End date
description: Date until which schedule should be executed
example: '["2021-12-31"]'
required: false
selector:
object:
timeslots:
name: Timeslots
description: list of timeslots with their actions and optionally conditions (should be kept the same for all timeslots)
example: '[{start: "12:00", stop: "13:00", actions: [{service: "light.turn_on", entity_id: "light.my_lamp", service_data: {brightness: 200}}]}]'
required: false
selector:
object:
repeat_type:
name: Repeat Type
description: Control what happens after the schedule is triggered
example: '"repeat"'
required: false
selector:
select:
options:
- repeat
- single
- pause
name:
name: Name
description: Friendly name for the schedule
required: false
example: "My schedule"
selector:
text:
copy:
name: Copy
description: Duplicate a schedule entity
fields:
entity_id:
name: Entity
description: Identifier of the scheduler entity.
example: "switch.schedule_abcdef"
required: true
selector:
entity:
integration: scheduler
domain: switch
name:
name: Name
description: Friendly name for the copied schedule
required: false
example: "My schedule"
selector:
text:
disable_all:
name: Disable All
description: Disables all schedules
enable_all:
name: Enable All
description: Enables all schedules
+376
View File
@@ -0,0 +1,376 @@
import logging
import secrets
from collections import OrderedDict
from typing import MutableMapping, cast
import attr
from homeassistant.core import callback, HomeAssistant
from homeassistant.loader import bind_hass
from homeassistant.const import (
ATTR_NAME,
CONF_CONDITIONS,
)
from homeassistant.helpers.storage import Store
from . import const
_LOGGER = logging.getLogger(__name__)
DATA_REGISTRY = f"{const.DOMAIN}_storage"
STORAGE_KEY = f"{const.DOMAIN}.storage"
STORAGE_VERSION = 3
SAVE_DELAY = 10
@attr.s(slots=True, frozen=True)
class ActionEntry:
"""Action storage Entry."""
service = attr.ib(type=str, default="")
entity_id = attr.ib(type=str, default=None)
service_data = attr.ib(type=dict, default={})
@attr.s(slots=True, frozen=True)
class ConditionEntry:
"""Condition storage Entry."""
entity_id = attr.ib(type=str, default=None)
attribute = attr.ib(type=str, default=None)
value = attr.ib(type=str, default=None)
match_type = attr.ib(type=str, default=None)
@attr.s(slots=True, frozen=True)
class TimeslotEntry:
"""Timeslot storage Entry."""
start = attr.ib(type=str, default=None)
stop = attr.ib(type=str, default=None)
conditions = attr.ib(type=[ConditionEntry], default=[])
condition_type = attr.ib(type=str, default=None)
track_conditions = attr.ib(type=bool, default=False)
actions = attr.ib(type=[ActionEntry], default=[])
@attr.s(slots=True, frozen=True)
class ScheduleEntry:
"""Schedule storage Entry."""
schedule_id = attr.ib(type=str, default=None)
weekdays = attr.ib(type=list, default=[])
start_date = attr.ib(type=str, default=None)
end_date = attr.ib(type=str, default=None)
timeslots = attr.ib(type=[TimeslotEntry], default=[])
repeat_type = attr.ib(type=str, default=None)
name = attr.ib(type=str, default=None)
enabled = attr.ib(type=bool, default=True)
@attr.s(slots=True, frozen=True)
class TagEntry:
"""Tag storage Entry."""
name = attr.ib(type=str, default=None)
schedules = attr.ib(type=[str], default=[])
def parse_schedule_data(data: dict):
if const.ATTR_TIMESLOTS in data:
timeslots = []
for item in data[const.ATTR_TIMESLOTS]:
timeslot = TimeslotEntry(**item)
if CONF_CONDITIONS in item and item[CONF_CONDITIONS]:
conditions = []
for condition in item[CONF_CONDITIONS]:
conditions.append(ConditionEntry(**condition))
timeslot = attr.evolve(timeslot, **{CONF_CONDITIONS: conditions})
if const.ATTR_ACTIONS in item and item[const.ATTR_ACTIONS]:
actions = []
for action in item[const.ATTR_ACTIONS]:
actions.append(ActionEntry(**action))
timeslot = attr.evolve(timeslot, **{const.ATTR_ACTIONS: actions})
timeslots.append(timeslot)
data[const.ATTR_TIMESLOTS] = timeslots
return data
class MigratableStore(Store):
async def _async_migrate_func(self, old_version, data: dict):
def remove_unequal_number_conditions(timeslots):
"""ensure all timeslots have the same number of conditions"""
if len(timeslots) > 1 and not all(
len(el["conditions"]) == len(timeslots[0]["conditions"])
for el in timeslots
):
return [
{
**slot,
"conditions": timeslots[0]["conditions"]
}
for slot in timeslots
]
return timeslots
if old_version < 2:
data["schedules"] = (
[
{
**entry,
const.ATTR_START_DATE: entry[const.ATTR_START_DATE]
if const.ATTR_START_DATE in entry
else None,
const.ATTR_END_DATE: entry[const.ATTR_END_DATE]
if const.ATTR_END_DATE in entry
else None,
}
for entry in data["schedules"]
]
if "schedules" in data
else []
)
if old_version < 3:
data["schedules"] = (
[
{
**entry,
const.ATTR_TIMESLOTS: remove_unequal_number_conditions(entry[const.ATTR_TIMESLOTS])
}
for entry in data["schedules"]
]
if "schedules" in data
else []
)
return data
class ScheduleStorage:
"""Class to hold scheduler data."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the storage."""
self.hass = hass
self.schedules: MutableMapping[str, ScheduleEntry] = {}
self.tags: MutableMapping[str, TagEntry] = {}
self.time_shutdown = None
self._store = MigratableStore(hass, STORAGE_VERSION, STORAGE_KEY)
async def async_load(self) -> None:
"""Load the registry of schedule entries."""
data = await self._store.async_load()
schedules: "OrderedDict[str, ScheduleEntry]" = OrderedDict()
tags: "OrderedDict[str, TagEntry]" = OrderedDict()
if data is not None:
if "schedules" in data:
for entry in data["schedules"]:
entry = parse_schedule_data(entry)
schedules[entry[const.ATTR_SCHEDULE_ID]] = ScheduleEntry(
schedule_id=entry[const.ATTR_SCHEDULE_ID],
weekdays=entry[const.ATTR_WEEKDAYS],
start_date=entry[const.ATTR_START_DATE],
end_date=entry[const.ATTR_END_DATE],
timeslots=entry[const.ATTR_TIMESLOTS],
repeat_type=entry[const.ATTR_REPEAT_TYPE],
name=entry[ATTR_NAME],
enabled=entry[const.ATTR_ENABLED],
)
if "tags" in data:
for entry in data["tags"]:
tags[entry[ATTR_NAME]] = TagEntry(
name=entry[ATTR_NAME],
schedules=entry[const.ATTR_SCHEDULES],
)
if "time_shutdown" in data:
self.time_shutdown = data["time_shutdown"]
self.schedules = schedules
self.tags = tags
@callback
def async_schedule_save(self) -> None:
"""Schedule saving the registry of schedules."""
self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
async def async_save(self) -> None:
"""Save the registry of schedules."""
await self._store.async_save(self._data_to_save())
@callback
def _data_to_save(self) -> dict:
"""Return data for the registry for schedules to store in a file."""
store_data = {}
store_data["schedules"] = []
store_data["tags"] = []
for entry in self.schedules.values():
item = {
const.ATTR_SCHEDULE_ID: entry.schedule_id,
const.ATTR_TIMESLOTS: [],
const.ATTR_WEEKDAYS: entry.weekdays,
const.ATTR_START_DATE: entry.start_date,
const.ATTR_END_DATE: entry.end_date,
const.ATTR_REPEAT_TYPE: entry.repeat_type,
ATTR_NAME: entry.name,
const.ATTR_ENABLED: entry.enabled,
}
for slot in entry.timeslots:
timeslot = {
const.ATTR_START: slot.start,
const.ATTR_STOP: slot.stop,
CONF_CONDITIONS: [],
const.ATTR_CONDITION_TYPE: slot.condition_type,
const.ATTR_TRACK_CONDITIONS: slot.track_conditions,
const.ATTR_ACTIONS: [],
}
if slot.conditions:
for condition in slot.conditions:
timeslot[CONF_CONDITIONS].append(attr.asdict(condition))
if slot.actions:
for action in slot.actions:
timeslot[const.ATTR_ACTIONS].append(attr.asdict(action))
item[const.ATTR_TIMESLOTS].append(timeslot)
store_data["schedules"].append(item)
store_data["tags"] = [attr.asdict(entry) for entry in self.tags.values()]
if self.time_shutdown:
store_data["time_shutdown"] = self.time_shutdown
return store_data
async def async_delete(self):
"""Delete config."""
_LOGGER.warning("Removing scheduler configuration data!")
self.schedules = {}
self.tags = {}
await self._store.async_remove()
@callback
def async_get_schedule(self, entity_id) -> dict:
"""Get an existing ScheduleEntry by id."""
res = self.schedules.get(entity_id)
return attr.asdict(res) if res else None
@callback
def async_get_schedules(self) -> dict:
"""Get an existing ScheduleEntry by id."""
res = {}
for (key, val) in self.schedules.items():
res[key] = attr.asdict(val)
return res
@callback
def async_create_schedule(self, data: dict) -> ScheduleEntry:
"""Create a new ScheduleEntry."""
if const.ATTR_SCHEDULE_ID in data:
schedule_id = data[const.ATTR_SCHEDULE_ID]
del data[const.ATTR_SCHEDULE_ID]
if schedule_id in self.schedules:
return
else:
schedule_id = secrets.token_hex(3)
while schedule_id in self.schedules:
schedule_id = secrets.token_hex(3)
data = parse_schedule_data(data)
new_schedule = ScheduleEntry(**data, schedule_id=schedule_id)
self.schedules[schedule_id] = new_schedule
self.async_schedule_save()
return new_schedule
@callback
def async_delete_schedule(self, schedule_id: str) -> None:
"""Delete ScheduleEntry."""
if schedule_id in self.schedules:
del self.schedules[schedule_id]
self.async_schedule_save()
return True
return False
@callback
def async_update_schedule(self, schedule_id: str, changes: dict) -> ScheduleEntry:
"""Update existing ScheduleEntry."""
old = self.schedules[schedule_id]
changes = parse_schedule_data(changes)
new = self.schedules[schedule_id] = attr.evolve(old, **changes)
self.async_schedule_save()
return new
@callback
def async_get_tag(self, name: str) -> dict:
"""Get an existing TagEntry by id."""
res = self.tags.get(name)
return attr.asdict(res) if res else None
@callback
def async_get_tags(self) -> dict:
"""Get an existing TagEntry by id."""
res = {}
for (key, val) in self.tags.items():
res[key] = attr.asdict(val)
return res
@callback
def async_create_tag(self, data: dict) -> TagEntry:
"""Create a new TagEntry."""
name = data[ATTR_NAME] if ATTR_NAME in data else None
if not name or name in data:
return None
new_tag = TagEntry(**data)
self.tags[name] = new_tag
self.async_schedule_save()
return new_tag
@callback
def async_delete_tag(self, name: str) -> None:
"""Delete TagEntry."""
if name in self.tags:
del self.tags[name]
self.async_schedule_save()
return True
return False
@callback
def async_update_tag(self, name: str, changes: dict) -> TagEntry:
"""Update existing TagEntry."""
old = self.tags[name]
changes = parse_schedule_data(changes)
new = self.tags[name] = attr.evolve(old, **changes)
self.async_schedule_save()
return new
@callback
def async_get_time_shutdown(self) -> dict:
"""Get the shutdown time and clear the stored value afterwards."""
res = self.time_shutdown
self.time_shutdown = None
self.async_schedule_save()
return res
@callback
async def async_set_time_shutdown(self, value: str):
"""Set the shutdown time and store it immediately."""
self.time_shutdown = value
await self.async_save()
@bind_hass
async def async_get_registry(hass: HomeAssistant) -> ScheduleStorage:
"""Return alarmo storage instance."""
task = hass.data.get(DATA_REGISTRY)
if task is None:
async def _load_reg() -> ScheduleStorage:
registry = ScheduleStorage(hass)
await registry.async_load()
return registry
task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg())
return cast(ScheduleStorage, await task)
+539
View File
@@ -0,0 +1,539 @@
"""Initialization of Scheduler switch platform."""
import copy
import datetime
import logging
import voluptuous as vol
import homeassistant.util.dt as dt_util
from homeassistant.components.switch import DOMAIN as PLATFORM
from homeassistant.helpers import entity_platform, config_validation as cv
from homeassistant.const import (
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
ATTR_ENTITY_ID,
ATTR_NAME,
ATTR_TIME,
CONF_SERVICE,
ATTR_SERVICE_DATA,
CONF_SERVICE_DATA,
CONF_CONDITIONS,
)
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
from homeassistant.core import callback
from homeassistant.helpers.entity import ToggleEntity, EntityCategory
from homeassistant.helpers.event import (
async_call_later,
)
from homeassistant.util import slugify
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
)
from . import const
from .store import ScheduleEntry, async_get_registry
from .timer import TimerHandler
from .actions import ActionHandler
_LOGGER = logging.getLogger(__name__)
SERVICE_RUN_ACTION = "run_action"
RUN_ACTION_SCHEMA = cv.make_entity_service_schema(
{vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(ATTR_TIME): cv.time, vol.Optional(const.ATTR_SKIP_CONDITIONS): cv.boolean}
)
def entity_exists_in_hass(hass, entity_id):
"""Check that an entity exists."""
return hass.states.get(entity_id) is not None
def date_in_future(date_string: str):
now = dt_util.as_local(dt_util.utcnow())
date = dt_util.parse_date(date_string)
diff = date - now.date()
return diff.days > 0
async def async_setup(hass, config):
"""Track states and offer events for binary sensors."""
return True
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the platform from config."""
return True
async def async_setup_entry(hass, _config_entry, async_add_entities):
"""Set up the Scheduler switch devices."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
@callback
def async_add_entity(schedule: ScheduleEntry):
"""Add switch for Scheduler."""
schedule_id = schedule.schedule_id
name = schedule.name
if name and len(slugify(name)):
entity_id = "{}.schedule_{}".format(PLATFORM, slugify(name))
else:
entity_id = "{}.schedule_{}".format(PLATFORM, schedule_id)
entity = ScheduleEntity(coordinator, hass, schedule_id, entity_id)
hass.data[const.DOMAIN]["schedules"][schedule_id] = entity
async_add_entities([entity])
for entry in coordinator.store.schedules.values():
async_add_entity(entry)
async_dispatcher_connect(hass, const.EVENT_ITEM_CREATED, async_add_entity)
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
SERVICE_RUN_ACTION, RUN_ACTION_SCHEMA, "async_service_run_action"
)
class ScheduleEntity(ToggleEntity):
"""Defines a base schedule entity."""
def __init__(self, coordinator, hass, schedule_id: str, entity_id: str) -> None:
"""Initialize the schedule entity."""
self.coordinator = coordinator
self.hass = hass
self.schedule_id = schedule_id
self.entity_id = entity_id
self.schedule = None
self._state = None
self._timer = None
self._timestamps = []
self._next_entries = []
self._current_slot = None
self._init = True
self._tags = []
self._listeners = [
async_dispatcher_connect(
self.hass, const.EVENT_ITEM_UPDATED, self.async_item_updated
),
async_dispatcher_connect(
self.hass, const.EVENT_TIMER_UPDATED, self.async_timer_updated
),
async_dispatcher_connect(
self.hass, const.EVENT_TIMER_FINISHED, self.async_timer_finished
),
]
@callback
async def async_item_updated(self, id: str):
"""update internal properties when schedule config was changed"""
if id != self.schedule_id:
return
store = await async_get_registry(self.hass)
self.schedule = store.async_get_schedule(self.schedule_id)
self._tags = self.coordinator.async_get_tags_for_schedule(self.schedule_id)
if self.schedule[const.ATTR_ENABLED] and self._state in [
STATE_OFF,
const.STATE_COMPLETED,
]:
self._state = STATE_ON
elif not self.schedule[const.ATTR_ENABLED] and self._state not in [
STATE_OFF,
const.STATE_COMPLETED,
]:
self._state = STATE_OFF
self._init = True # trigger actions of starting timeslot
if self.hass is None:
return
self.async_write_ha_state()
self.hass.bus.async_fire(const.EVENT)
@callback
async def async_timer_updated(self, id: str):
"""update internal properties when schedule timer was changed"""
if id != self.schedule_id:
return
self._next_entries = self._timer_handler.slot_queue
self._timestamps = list(
map(
lambda x: datetime.datetime.isoformat(x), self._timer_handler.timestamps
)
)
if self._current_slot is not None and self._timer_handler.current_slot is None:
# we are leaving a timeslot, stop execution of actions
if (
len(self.schedule[const.ATTR_TIMESLOTS]) == 1
and self.schedule[const.ATTR_REPEAT_TYPE] == const.REPEAT_TYPE_REPEAT
):
# allow unavailable entities to restore within 9 mins (+1 minute of triggered duration)
await self._action_handler.async_empty_queue(restore_time=9)
else:
await self._action_handler.async_empty_queue()
if self._current_slot == (
len(self.schedule[const.ATTR_TIMESLOTS]) - 1
) and (
not self.schedule[const.ATTR_END_DATE]
or not date_in_future(self.schedule[const.ATTR_END_DATE])
):
# last timeslot has ended
# in case period is assigned, the end date must have been reached as well
if self.schedule[const.ATTR_REPEAT_TYPE] == const.REPEAT_TYPE_PAUSE:
_LOGGER.debug(
"Scheduler {} has finished the last timeslot, turning off".format(
self.schedule_id
)
)
await self.async_turn_off()
self._state = const.STATE_COMPLETED
elif self.schedule[const.ATTR_REPEAT_TYPE] == const.REPEAT_TYPE_SINGLE:
_LOGGER.debug(
"Scheduler {} has finished the last timeslot, removing".format(
self.schedule_id
)
)
self.coordinator.async_delete_schedule(self.schedule_id)
self._current_slot = self._timer_handler.current_slot
if self._state not in [STATE_OFF, AlarmControlPanelState.TRIGGERED]:
if len(self._next_entries) < 1:
self._state = STATE_UNAVAILABLE
else:
now = dt_util.as_local(dt_util.utcnow())
if (self._timer_handler._next_trigger - now).total_seconds() < 0:
self._state = const.STATE_COMPLETED
else:
self._state = (
STATE_ON if self.schedule[const.ATTR_ENABLED] else STATE_OFF
)
if self._init:
# initial startpoint for timer calculated, fire actions if currently overlapping with timeslot
if self._current_slot is not None and self._state != STATE_OFF:
skip_initial_execution = False
if self.coordinator.state == const.STATE_INIT and self.coordinator.time_shutdown:
# if the date+time of prior shutdown is known, determine which timeslots are already triggered before
# calculate the next start of timeslot since the time of shutdown, execute only if this is in the past
ts_shutdown = self.coordinator.time_shutdown
now = dt_util.as_local(dt_util.utcnow())
start_time = self.schedule[const.ATTR_TIMESLOTS][self._current_slot][const.ATTR_START]
start_of_timeslot = self._timer_handler.calculate_timestamp(start_time, ts_shutdown)
if start_of_timeslot > now:
skip_initial_execution = True
if skip_initial_execution:
_LOGGER.debug(
"Schedule {} was already executed before shutdown, initial timeslot is skipped.".format(
self.schedule_id
)
)
else:
_LOGGER.debug(
"Schedule {} is starting in a timeslot, proceed with actions".format(
self.schedule_id
)
)
await self._action_handler.async_queue_actions(
self.schedule[const.ATTR_TIMESLOTS][self._current_slot],
skip_initial_execution
)
self._init = False
if self.hass is None:
return
self.async_write_ha_state()
self.hass.bus.async_fire(const.EVENT)
@callback
async def async_timer_finished(self, id: str):
"""fire actions when timer is finished"""
if id != self.schedule_id:
return
if self._state not in [STATE_OFF, const.STATE_COMPLETED]:
self._current_slot = self._timer_handler.current_slot
if self._current_slot is not None:
_LOGGER.debug(
"Schedule {} is triggered, proceed with actions".format(
self.schedule_id
)
)
await self._action_handler.async_queue_actions(
self.schedule[const.ATTR_TIMESLOTS][self._current_slot]
)
@callback
async def async_trigger_finished(_now):
"""internal timer is finished, reset the schedule"""
if self._state == AlarmControlPanelState.TRIGGERED:
self._state = STATE_ON
await self._timer_handler.async_start_timer()
# keep the entity in triggered state for 1 minute, then restart the timer
self._timer = async_call_later(self.hass, 60, async_trigger_finished)
if self._state == STATE_ON:
self._state = AlarmControlPanelState.TRIGGERED
self.async_write_ha_state()
self.hass.bus.async_fire(const.EVENT)
async def async_cancel_timer(self):
"""cancel timer"""
if self._timer:
self._timer()
self._timer = None
@property
def device_info(self) -> dict:
"""Return info for device registry."""
device = self.coordinator.id
return {
"identifiers": {(const.DOMAIN, device)},
"name": "Scheduler",
"model": "Scheduler",
"sw_version": const.VERSION,
"manufacturer": "@nielsfaber",
}
@property
def name(self) -> str:
"""Return the name of the entity."""
if self.schedule and self.schedule[ATTR_NAME]:
return self.schedule[ATTR_NAME]
else:
return "Schedule #{}".format(self.schedule_id)
@property
def should_poll(self) -> bool:
"""Return the polling requirement of the entity."""
return False
@property
def state(self):
"""Return the state of the entity."""
return self._state
@property
def icon(self):
"""Return icon."""
return "mdi:calendar-clock"
@property
def entity_category(self):
"""Return EntityCategory."""
return EntityCategory.CONFIG
@property
def weekdays(self):
return self.schedule[const.ATTR_WEEKDAYS] if self.schedule else None
@property
def entities(self):
entities = []
if not self.schedule:
return
for timeslot in self.schedule[const.ATTR_TIMESLOTS]:
for action in timeslot[const.ATTR_ACTIONS]:
if action[ATTR_ENTITY_ID] and action[ATTR_ENTITY_ID] not in entities:
entities.append(action[ATTR_ENTITY_ID])
return entities
@property
def actions(self):
if not self.schedule:
return
return [
{
CONF_SERVICE: timeslot["actions"][0][CONF_SERVICE],
}
if not timeslot["actions"][0][ATTR_SERVICE_DATA]
else {
CONF_SERVICE: timeslot["actions"][0][CONF_SERVICE],
CONF_SERVICE_DATA: timeslot["actions"][0][ATTR_SERVICE_DATA],
}
for timeslot in self.schedule[const.ATTR_TIMESLOTS]
]
@property
def timeslots(self):
timeslots = []
if not self.schedule:
return
for timeslot in self.schedule[const.ATTR_TIMESLOTS]:
if timeslot[const.ATTR_STOP]:
timeslots.append(
"{} - {}".format(
timeslot[const.ATTR_START], timeslot[const.ATTR_STOP]
)
)
else:
timeslots.append(timeslot[const.ATTR_START])
return timeslots
@property
def tags(self):
return self._tags
@property
def state_attributes(self):
"""Return the data of the entity."""
output = {
"weekdays": self.weekdays,
"timeslots": self.timeslots,
"entities": self.entities,
"actions": self.actions,
"current_slot": self._current_slot,
"next_slot": self._next_entries[0] if len(self._next_entries) else None,
"next_trigger": self._timestamps[self._next_entries[0]]
if len(self._next_entries)
else None,
"tags": self.tags,
}
return output
@property
def available(self):
"""Return True if entity is available."""
return True
@property
def unique_id(self):
"""Return a unique ID to use for this entity."""
return f"{self.schedule_id}"
@property
def is_on(self):
"""Return true if entity is on."""
return self._state not in [STATE_OFF, const.STATE_COMPLETED]
@callback
def async_get_entity_state(self):
"""fetch schedule data for websocket API"""
data = copy.copy(self.schedule)
if not data:
data = {}
data.update(
{
"next_entries": self._next_entries,
"timestamps": self._timestamps,
"name": self.schedule[ATTR_NAME] if self.schedule else "",
"entity_id": self.entity_id,
"tags": self.tags,
}
)
return data
async def async_added_to_hass(self):
"""Connect to dispatcher listening for entity data notifications."""
store = await async_get_registry(self.hass)
self.schedule = store.async_get_schedule(self.schedule_id)
self._tags = self.coordinator.async_get_tags_for_schedule(self.schedule_id)
self._timer_handler = TimerHandler(self.hass, self.schedule_id)
self._action_handler = ActionHandler(self.hass, self.schedule_id)
async def async_turn_off(self):
"""turn off a schedule"""
if self.schedule[const.ATTR_ENABLED]:
await self._action_handler.async_empty_queue()
self.coordinator.async_edit_schedule(
self.schedule_id, {const.ATTR_ENABLED: False}
)
async def async_turn_on(self):
"""turn on a schedule"""
if not self.schedule[const.ATTR_ENABLED]:
self.coordinator.async_edit_schedule(
self.schedule_id, {const.ATTR_ENABLED: True}
)
async def async_will_remove_from_hass(self):
"""remove entity from hass."""
_LOGGER.debug("Schedule {} is removed from hass".format(self.schedule_id))
await self.async_cancel_timer()
await self._action_handler.async_empty_queue()
await self._timer_handler.async_unload()
while len(self._listeners):
self._listeners.pop()()
await super().async_will_remove_from_hass()
async def async_service_remove(self):
"""remove a schedule"""
self._state = STATE_OFF
await self.async_remove()
async def async_service_edit(
self, entries, actions, conditions=None, options=None, name=None
):
"""edit a schedule"""
if self._timer:
old_state = self._state
self._state = STATE_OFF
self._timer()
self._timer = None
self._state = old_state
await self.async_cancel_timer()
await self._action_handler.async_empty_queue()
await self._timer_handler.async_unload()
self.async_write_ha_state()
async def async_service_run_action(self, time=None, skip_conditions=False):
"""Manually trigger the execution of the actions of a timeslot"""
now = dt_util.as_local(dt_util.utcnow())
if time is not None:
now = now.replace(hour=time.hour, minute=time.minute, second=time.second)
(slot, ts) = self._timer_handler.current_timeslot(now)
if (
slot is None
and time is None
and len(self.schedule[const.ATTR_TIMESLOTS]) == 1
):
slot = 0
if slot is None:
_LOGGER.info(
"Schedule {} has no active timeslot at {}".format(
self.entity_id, now.strftime("%H:%M:%S")
)
)
return
schedule = dict(self.schedule[const.ATTR_TIMESLOTS][slot])
if skip_conditions:
schedule[CONF_CONDITIONS] = []
_LOGGER.debug(
"Executing actions for {}, timeslot {}, skip_conditions {}".format(self.entity_id, slot, skip_conditions)
)
await self._action_handler.async_queue_actions(
schedule
)
+509
View File
@@ -0,0 +1,509 @@
import logging
import datetime
import homeassistant.util.dt as dt_util
from homeassistant.const import (
WEEKDAYS,
STATE_ON,
STATE_OFF,
)
from homeassistant.core import (
HomeAssistant,
callback,
)
from homeassistant.helpers.event import (
async_track_point_in_time,
async_track_state_change_event,
)
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from . import const
from .store import async_get_registry
_LOGGER = logging.getLogger(__name__)
ATTR_NEXT_RISING = "next_rising"
ATTR_NEXT_SETTING = "next_setting"
ATTR_WORKDAYS = "workdays"
def has_sun(time_str: str):
return const.OffsetTimePattern.match(time_str)
def is_same_day(dateA: datetime.datetime, dateB: datetime.datetime):
return dateA.date() == dateB.date()
def days_until_date(date_string: str, ts: datetime.datetime):
date = dt_util.parse_date(date_string)
diff = date - ts.date()
return diff.days
def find_closest_from_now(date_arr: list):
now = dt_util.as_local(dt_util.utcnow())
minimum = None
for item in date_arr:
if item is not None:
if minimum is None:
minimum = item
elif item > now:
if item < minimum or minimum < now:
minimum = item
else:
if item < minimum and minimum < now:
minimum = item
return minimum
class TimerHandler:
def __init__(self, hass: HomeAssistant, id: str):
"""init"""
self.hass = hass
self.id = id
self._weekdays = []
self._start_date = None
self._end_date = None
self._timeslots = []
self._timer = None
self._next_trigger = None
self._next_slot = None
self._sun_tracker = None
self._workday_tracker = None
self._watched_times = []
self.slot_queue = []
self.timestamps = []
self.current_slot = None
self.hass.loop.create_task(self.async_reload_data())
@callback
async def async_item_updated(id: str):
if id == self.id:
await self.async_reload_data()
self._update_listener = async_dispatcher_connect(
self.hass, const.EVENT_ITEM_UPDATED, async_item_updated
)
async def async_reload_data(self):
"""load schedule data into timer class object and start timer"""
store = await async_get_registry(self.hass)
data = store.async_get_schedule(self.id)
self._weekdays = data[const.ATTR_WEEKDAYS]
self._start_date = data[const.ATTR_START_DATE]
self._end_date = data[const.ATTR_END_DATE]
self._timeslots = [
dict((k, slot[k]) for k in [const.ATTR_START, const.ATTR_STOP] if k in slot)
for slot in data[const.ATTR_TIMESLOTS]
]
await self.async_start_timer()
async def async_unload(self):
"""unload a timer class object"""
await self.async_stop_timer()
self._update_listener()
self._next_trigger = None
async def async_start_timer(self):
[current_slot, timestamp_end] = self.current_timeslot()
[next_slot, timestamp_next] = self.next_timeslot()
self._watched_times = []
if timestamp_next is not None:
self._watched_times.append(self._timeslots[next_slot][const.ATTR_START])
if timestamp_end is not None:
self._watched_times.append(self._timeslots[current_slot][const.ATTR_STOP])
# the next trigger time is next slot or end of current slot (whichever comes first)
timestamp = find_closest_from_now([timestamp_end, timestamp_next])
self._timer_is_endpoint = (
timestamp != timestamp_next and timestamp == timestamp_end
)
if timestamp == timestamp_next and timestamp is not None:
self._next_slot = next_slot
else:
self._next_slot = None
self.current_slot = current_slot
self._next_trigger = timestamp
await self.async_start_sun_tracker()
now = dt_util.as_local(dt_util.utcnow())
if timestamp is not None:
if self._timer:
self._timer()
if (timestamp - now).total_seconds() < 0:
self._timer = None
_LOGGER.debug(
"Timer of {} is not set because it is in the past ({})".format(
self.id, timestamp
)
)
else:
self._timer = async_track_point_in_time(
self.hass, self.async_timer_finished, timestamp
)
_LOGGER.debug("Timer of {} set for {}".format(self.id, timestamp))
await self.async_start_workday_tracker()
async_dispatcher_send(self.hass, const.EVENT_TIMER_UPDATED, self.id)
async def async_stop_timer(self):
"""stop the timer"""
if self._timer:
self._timer()
self._timer = None
await self.async_stop_sun_tracker()
await self.async_stop_workday_tracker()
async def async_start_sun_tracker(self):
"""check for changes in the sun sensor"""
if (
self._next_trigger is not None
and any(has_sun(x) for x in self._watched_times)
) or (
self._next_trigger is None
and all(has_sun(x[const.ATTR_START]) for x in self._timeslots)
):
# install sun tracker for updating timer when sun changes
# initially the time calculation may fail due to the sun entity being unavailable
if self._sun_tracker is not None:
# the tracker is already running
return
@callback
async def async_sun_updated(_event):
"""the sun entity was updated"""
# sun entity changed
if self._next_trigger is None:
# sun entity has initialized
await self.async_start_timer()
return
ts = find_closest_from_now(
self.calculate_timestamp(x) for x in self._watched_times
)
if not ts or not self._next_trigger:
# sun entity became unavailable (or other corner case)
await self.async_start_timer()
return
# we are re-scheduling an existing timer
delta = (ts - self._next_trigger).total_seconds()
if abs(delta) >= 60 and abs(delta) < 2000:
# only reschedule if the difference is at least a minute
# only reschedule if this doesnt cause the timer to shift to another day (+/- 24 hrs delta)
# only reschedule if this doesnt cause the timer to shift to another hour (due to DST change)
await self.async_start_timer()
self._sun_tracker = async_track_state_change_event(
self.hass, const.SUN_ENTITY, async_sun_updated
)
else:
# clear existing tracker
await self.async_stop_sun_tracker()
async def async_stop_sun_tracker(self):
"""stop checking for changes in the sun sensor"""
if self._sun_tracker:
self._sun_tracker()
self._sun_tracker = None
async def async_start_workday_tracker(self):
"""check for changes in the workday sensor"""
if (
const.DAY_TYPE_WORKDAY in self._weekdays
or const.DAY_TYPE_WEEKEND in self._weekdays
):
# install tracker for updating timer when workday sensor changes
if self._workday_tracker is not None:
# the tracker is already running
return
@callback
async def async_workday_updated():
"""the workday sensor was updated"""
[current_slot, timestamp_end] = self.current_timeslot()
[next_slot, timestamp_next] = self.next_timeslot()
ts_next = find_closest_from_now([timestamp_end, timestamp_next])
# workday entity changed
if not ts_next or not self._next_trigger:
# timer was not yet set
await self.async_start_timer()
else:
# we are re-scheduling an existing timer
delta = (ts_next - self._next_trigger).total_seconds()
if abs(delta) >= 60:
# only reschedule if the difference is at least a minute
await self.async_start_timer()
self._workday_tracker = async_dispatcher_connect(
self.hass, const.EVENT_WORKDAY_SENSOR_UPDATED, async_workday_updated
)
else:
# clear existing tracker
await self.async_stop_workday_tracker()
async def async_stop_workday_tracker(self):
"""stop checking for changes in the workday sensor"""
if self._workday_tracker:
self._workday_tracker()
self._workday_tracker = None
async def async_timer_finished(self, _time):
"""the timer is finished"""
if not self._timer_is_endpoint:
# timer marks the start of a new timeslot
self.current_slot = self._next_slot
_LOGGER.debug(
"Timer {} has reached slot {}".format(self.id, self.current_slot)
)
async_dispatcher_send(self.hass, const.EVENT_TIMER_FINISHED, self.id)
# don't automatically reset, wait for external reset after 1 minute
# await self.async_start_timer()
await self.async_stop_timer()
else:
# timer marks the end of a timeslot
_LOGGER.debug(
"Timer {} has reached end of timeslot, resetting..".format(self.id)
)
await self.async_start_timer()
def day_in_weekdays(self, ts: datetime.datetime) -> bool:
"""check if the day of a datetime object is in the allowed list of days"""
day = WEEKDAYS[ts.weekday()]
workday_sensor = self.hass.states.get(const.WORKDAY_ENTITY)
if (
workday_sensor
and workday_sensor.state in [STATE_ON, STATE_OFF]
and is_same_day(ts, dt_util.as_local(dt_util.utcnow()))
):
# state of workday sensor is used for evaluating workday vs weekend
if const.DAY_TYPE_WORKDAY in self._weekdays:
return workday_sensor.state == STATE_ON
elif const.DAY_TYPE_WEEKEND in self._weekdays:
return workday_sensor.state == STATE_OFF
if workday_sensor and ATTR_WORKDAYS in workday_sensor.attributes:
# workday sensor defines a list of workdays
workday_list = workday_sensor.attributes[ATTR_WORKDAYS]
weekend_list = [e for e in WEEKDAYS if e not in workday_list]
else:
# assume workdays are mon-fri
workday_list = WEEKDAYS[0:5]
weekend_list = WEEKDAYS[5:7]
if const.DAY_TYPE_DAILY in self._weekdays or not len(self._weekdays):
return True
elif const.DAY_TYPE_WORKDAY in self._weekdays and day in workday_list:
return True
elif const.DAY_TYPE_WEEKEND in self._weekdays and day in weekend_list:
return True
return day in self._weekdays
def calculate_timestamp(
self,
time_str,
now: datetime.datetime = None,
iteration: int = 0,
reverse_direction: bool = False,
) -> datetime.datetime:
"""calculate the next occurence of a time string"""
if time_str is None:
return None
if now is None:
now = dt_util.as_local(dt_util.utcnow())
res = has_sun(time_str)
if not res:
# fixed time
time = dt_util.parse_time(time_str)
ts = dt_util.find_next_time_expression_time(
now, [time.second], [time.minute], [time.hour]
)
else:
# relative to sunrise/sunset
sun = self.hass.states.get(const.SUN_ENTITY)
if not sun:
return None
ts = None
if (
res.group(1) == const.SUN_EVENT_SUNRISE
and ATTR_NEXT_RISING in sun.attributes
):
ts = dt_util.parse_datetime(sun.attributes[ATTR_NEXT_RISING])
elif (
res.group(1) == const.SUN_EVENT_SUNSET
and ATTR_NEXT_SETTING in sun.attributes
):
ts = dt_util.parse_datetime(sun.attributes[ATTR_NEXT_SETTING])
if not ts:
return None
ts = dt_util.as_local(ts)
ts = ts.replace(second=0)
time_sun = datetime.timedelta(
hours=ts.hour, minutes=ts.minute, seconds=ts.second
)
offset = dt_util.parse_time(res.group(3))
offset = datetime.timedelta(
hours=offset.hour, minutes=offset.minute, seconds=offset.second
)
if res.group(2) == "-":
if (time_sun - offset).total_seconds() >= 0:
ts = ts - offset
else:
# prevent offset to shift the time past the extends of the day
ts = ts.replace(hour=0, minute=0, second=0)
else:
if (time_sun + offset).total_seconds() <= 86340:
ts = ts + offset
else:
# prevent offset to shift the time past the extends of the day
ts = ts.replace(hour=23, minute=59, second=0)
ts = dt_util.find_next_time_expression_time(
now, [ts.second], [ts.minute], [ts.hour]
)
time_delta = datetime.timedelta(seconds=1)
if self.day_in_weekdays(ts) and (
(ts - now).total_seconds() > 0 or iteration > 0
):
if self._start_date and days_until_date(self._start_date, ts) > 0:
# start date is in the future, jump to start date
end_of_day = ts.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
days_delta = days_until_date(self._start_date, end_of_day)
if days_delta:
time_delta = datetime.timedelta(days=days_delta)
elif self._end_date and days_until_date(self._end_date, ts) < 0:
# end date is in the past, jump to end date
time_delta = datetime.timedelta(
days=days_until_date(self._end_date, ts)
)
reverse_direction = True
else:
# date restrictions are met
return ts
elif reverse_direction:
time_delta = datetime.timedelta(days=-1)
# calculate next timestamp
next_day = dt_util.find_next_time_expression_time(
now + time_delta, [0], [0], [0]
)
if iteration > 15:
_LOGGER.warning(
"failed to calculate next timeslot for schedule {}".format(self.id)
)
return None
return self.calculate_timestamp(
time_str, next_day, iteration + 1, reverse_direction
)
def next_timeslot(self):
"""calculate the closest timeslot from now"""
now = dt_util.as_local(dt_util.utcnow())
# calculate next start of all timeslots
timestamps = [
self.calculate_timestamp(slot[const.ATTR_START], now)
for slot in self._timeslots
]
# calculate timeslot that will start soonest (or closest in the past)
remaining = [
abs((ts - now).total_seconds()) if ts is not None else now.timestamp()
for ts in timestamps
]
slot_order = sorted(range(len(remaining)), key=lambda k: remaining[k])
# filter out timeslots that cannot be computed
for i in range(len(timestamps)):
if timestamps[i] is None:
slot_order.remove(i)
timestamps = [e for e in timestamps if e is not None]
self.slot_queue = slot_order
self.timestamps = timestamps
next_slot = slot_order[0] if len(slot_order) > 0 else None
return (next_slot, timestamps[next_slot] if next_slot is not None else None)
def current_timeslot(self, now: datetime.datetime = None):
"""calculate the end of the timeslot that is overlapping now"""
if now is None:
now = dt_util.as_local(dt_util.utcnow())
def unwrap_end_of_day(time_str: str):
if time_str == "00:00:00":
return "23:59:59"
else:
return time_str
# calculate next stop of all timeslots
timestamps = []
for slot in self._timeslots:
if slot[const.ATTR_STOP] is not None:
timestamps.append(
self.calculate_timestamp(
unwrap_end_of_day(slot[const.ATTR_STOP]), now
)
)
else:
ts = self.calculate_timestamp(slot[const.ATTR_START], now)
if ts is None:
timestamps.append(None)
else:
ts = ts + datetime.timedelta(minutes=1)
timestamps.append(
self.calculate_timestamp(ts.strftime("%H:%M:%S"), now)
)
# calculate timeslot that will end soonest
remaining = [
(ts - now).total_seconds() if ts is not None else now.timestamp()
for ts in timestamps
]
(next_slot_end, val) = sorted(
enumerate(remaining), key=lambda i: (i[1] < 0, abs(i[1]))
)[0]
stop = timestamps[next_slot_end]
if stop is not None:
# calculate last start of timeslot that will end soonest
if (stop - now).total_seconds() < 0:
# end of timeslot is in the past
return (None, None)
start = self.calculate_timestamp(
self._timeslots[next_slot_end][const.ATTR_START],
stop - datetime.timedelta(days=1),
)
if start is not None:
elapsed = (now - start).total_seconds()
if elapsed > 0:
# timeslot is currently overlapping
return (
next_slot_end,
stop
if self._timeslots[next_slot_end][const.ATTR_STOP] is not None
else None,
)
return (None, None)
+272
View File
@@ -0,0 +1,272 @@
import logging
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import callback
from homeassistant.components.websocket_api import decorators, async_register_command
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import const
from .store import ScheduleEntry
_LOGGER = logging.getLogger(__name__)
class SchedulesListView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/{}/list".format(const.DOMAIN)
name = "api:{}:list".format(const.DOMAIN)
async def get(self, request):
hass = request.app["hass"]
coordinator = hass.data[const.DOMAIN]["coordinator"]
schedules = coordinator.async_get_schedules()
return self.json(schedules)
class SchedulesAddView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/{}/add".format(const.DOMAIN)
name = "api:{}:add".format(const.DOMAIN)
@RequestDataValidator(const.ADD_SCHEDULE_SCHEMA)
async def post(self, request, data):
"""Handle config update request."""
hass = request.app["hass"]
coordinator = hass.data[const.DOMAIN]["coordinator"]
coordinator.async_create_schedule(data)
return self.json({"success": True})
class SchedulesEditView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/{}/edit".format(const.DOMAIN)
name = "api:{}:edit".format(const.DOMAIN)
@RequestDataValidator(
const.EDIT_SCHEDULE_SCHEMA.extend(
{vol.Required(const.ATTR_SCHEDULE_ID): cv.string}
)
)
async def post(self, request, data):
"""Handle config update request."""
hass = request.app["hass"]
coordinator = hass.data[const.DOMAIN]["coordinator"]
schedule_id = data[const.ATTR_SCHEDULE_ID]
del data[const.ATTR_SCHEDULE_ID]
coordinator.async_edit_schedule(schedule_id, data)
return self.json({"success": True})
class SchedulesRemoveView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/{}/remove".format(const.DOMAIN)
name = "api:{}:remove".format(const.DOMAIN)
@RequestDataValidator(vol.Schema({vol.Required(const.ATTR_SCHEDULE_ID): cv.string}))
async def post(self, request, data):
"""Handle config update request."""
hass = request.app["hass"]
coordinator = hass.data[const.DOMAIN]["coordinator"]
coordinator.async_delete_schedule(data[const.ATTR_SCHEDULE_ID])
return self.json({"success": True})
@callback
def websocket_get_schedules(hass, connection, msg):
"""Publish scheduler list data."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
schedules = coordinator.async_get_schedules()
connection.send_result(msg["id"], schedules)
@callback
def websocket_get_schedule_item(hass, connection, msg):
"""Publish scheduler list data."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
item = msg[const.ATTR_SCHEDULE_ID]
data = coordinator.async_get_schedule(item)
connection.send_result(msg["id"], data)
@callback
def websocket_get_tags(hass, connection, msg):
"""Publish tag list data."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
tags = coordinator.async_get_tags()
connection.send_result(msg["id"], tags)
@callback
@decorators.websocket_command(
{
vol.Required("type"): const.EVENT,
}
)
@decorators.async_response
async def handle_subscribe_updates(hass, connection, msg):
"""subscribe listeners when frontend connection is opened"""
listeners = []
@callback
def async_handle_event_item_created(schedule: ScheduleEntry):
"""pass data to frontend when backend changes"""
connection.send_message(
{
"id": msg["id"],
"type": "event",
"event": { # data to pass with event
"event": const.EVENT_ITEM_CREATED,
"schedule_id": schedule.schedule_id,
},
}
)
listeners.append(
async_dispatcher_connect(
hass, const.EVENT_ITEM_CREATED, async_handle_event_item_created
)
)
@callback
def async_handle_event_item_updated(schedule_id: str):
"""pass data to frontend when backend changes"""
connection.send_message(
{
"id": msg["id"],
"type": "event",
"event": { # data to pass with event
"event": const.EVENT_ITEM_UPDATED,
"schedule_id": schedule_id,
},
}
)
listeners.append(
async_dispatcher_connect(
hass, const.EVENT_ITEM_UPDATED, async_handle_event_item_updated
)
)
@callback
def async_handle_event_item_removed(schedule_id: str):
"""pass data to frontend when backend changes"""
connection.send_message(
{
"id": msg["id"],
"type": "event",
"event": { # data to pass with event
"event": const.EVENT_ITEM_REMOVED,
"schedule_id": schedule_id,
},
}
)
listeners.append(
async_dispatcher_connect(
hass, const.EVENT_ITEM_REMOVED, async_handle_event_item_removed
)
)
@callback
def async_handle_event_timer_updated(schedule_id: str):
"""pass data to frontend when backend changes"""
connection.send_message(
{
"id": msg["id"],
"type": "event",
"event": { # data to pass with event
"event": const.EVENT_TIMER_UPDATED,
"schedule_id": schedule_id,
},
}
)
listeners.append(
async_dispatcher_connect(
hass, const.EVENT_TIMER_UPDATED, async_handle_event_timer_updated
)
)
@callback
def async_handle_event_timer_finished(schedule_id: str):
"""pass data to frontend when backend changes"""
connection.send_message(
{
"id": msg["id"],
"type": "event",
"event": { # data to pass with event
"event": const.EVENT_TIMER_FINISHED,
"schedule_id": schedule_id,
},
}
)
listeners.append(
async_dispatcher_connect(
hass, const.EVENT_TIMER_FINISHED, async_handle_event_timer_finished
)
)
def unsubscribe_listeners():
"""unsubscribe listeners when frontend connection closes"""
while len(listeners):
listeners.pop()()
connection.subscriptions[msg["id"]] = unsubscribe_listeners
connection.send_result(msg["id"])
async def async_register_websockets(hass):
# expose services
hass.http.register_view(SchedulesAddView)
hass.http.register_view(SchedulesEditView)
hass.http.register_view(SchedulesRemoveView)
hass.http.register_view(SchedulesListView)
# pass list of schedules to frontend
websocket_api.async_register_command(
hass,
const.DOMAIN,
websocket_get_schedules,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): const.DOMAIN,
}
),
)
# pass single schedule to frontend
websocket_api.async_register_command(
hass,
"{}/item".format(const.DOMAIN),
websocket_get_schedule_item,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): "{}/item".format(const.DOMAIN),
vol.Required(const.ATTR_SCHEDULE_ID): cv.string,
}
),
)
# pass list of tags to frontend
websocket_api.async_register_command(
hass,
"{}/tags".format(const.DOMAIN),
websocket_get_tags,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): "{}/tags".format(const.DOMAIN),
}
),
)
# instantiate listener for sending event to frontend on backend change
async_register_command(hass, handle_subscribe_updates)