540 lines
19 KiB
Python
540 lines
19 KiB
Python
"""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
|
|
)
|