447 lines
16 KiB
Python
447 lines
16 KiB
Python
"""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()
|