New apps Added
This commit is contained in:
@@ -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()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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={})
|
||||
@@ -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]),
|
||||
}
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user