New apps Added
This commit is contained in:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user