"""https://github.com/dummylabs/thewatchman§""" from datetime import timedelta import logging import os import time import json import voluptuous as vol from homeassistant.loader import async_get_integration from homeassistant.helpers import config_validation as cv from homeassistant.components import persistent_notification from homeassistant.util import dt as dt_util from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import HomeAssistantType from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_STATE_CHANGED, EVENT_CALL_SERVICE, STATE_UNKNOWN, ) from .coordinator import WatchmanCoordinator from .utils import ( is_service, report, parse, table_renderer, text_renderer, get_config, get_report_path, ) from .const import ( DOMAIN, DOMAIN_DATA, DEFAULT_HEADER, CONF_IGNORED_FILES, CONF_HEADER, CONF_REPORT_PATH, CONF_IGNORED_ITEMS, CONF_SERVICE_NAME, CONF_SERVICE_DATA, CONF_SERVICE_DATA2, CONF_INCLUDED_FOLDERS, CONF_CHECK_LOVELACE, CONF_IGNORED_STATES, CONF_CHUNK_SIZE, CONF_CREATE_FILE, CONF_SEND_NOTIFICATION, CONF_PARSE_CONFIG, CONF_COLUMNS_WIDTH, CONF_STARTUP_DELAY, CONF_FRIENDLY_NAMES, CONF_ALLOWED_SERVICE_PARAMS, CONF_TEST_MODE, EVENT_AUTOMATION_RELOADED, EVENT_SCENE_RELOADED, TRACKED_EVENT_DOMAINS, MONITORED_STATES, PLATFORMS, VERSION, ) _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Optional(CONF_REPORT_PATH): cv.string, vol.Optional(CONF_IGNORED_FILES): cv.ensure_list, vol.Optional(CONF_IGNORED_ITEMS): cv.ensure_list, vol.Optional(CONF_HEADER, default=DEFAULT_HEADER): cv.string, vol.Optional(CONF_SERVICE_NAME): cv.string, vol.Optional(CONF_SERVICE_DATA): vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Optional(CONF_INCLUDED_FOLDERS): cv.ensure_list, vol.Optional(CONF_CHECK_LOVELACE, default=False): cv.boolean, vol.Optional(CONF_CHUNK_SIZE, default=3500): cv.positive_int, vol.Optional(CONF_IGNORED_STATES): [ "missing", "unavailable", "unknown", ], vol.Optional(CONF_COLUMNS_WIDTH): cv.ensure_list, vol.Optional(CONF_STARTUP_DELAY, default=0): cv.positive_int, vol.Optional(CONF_FRIENDLY_NAMES, default=False): cv.boolean, } ) }, extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistantType, config: dict): """Set up is called when Home Assistant is loading our component.""" if config.get(DOMAIN) is None: # We get here if the integration is set up using config flow return True hass.data.setdefault(DOMAIN_DATA, config[DOMAIN]) hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=hass.data[DOMAIN_DATA] ) ) # Return boolean to indicate that initialization was successful. return True async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Set up this integration using UI""" _LOGGER.debug(entry.options) _LOGGER.debug("Home assistant path: %s", hass.config.path("")) coordinator = WatchmanCoordinator(hass, _LOGGER, name=entry.title) coordinator.async_set_updated_data(None) if not coordinator.last_update_success: raise ConfigEntryNotReady hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.data[DOMAIN]["coordinator"] = coordinator hass.data[DOMAIN_DATA] = entry.options # TODO: refactor await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) await add_services(hass) await add_event_handlers(hass) if hass.is_running: # integration reloaded or options changed via UI parse_config(hass, reason="changes in watchman configuration") await coordinator.async_config_entry_first_refresh() else: # first run, home assistant is loading # parse_config will be scheduled once HA is fully loaded _LOGGER.info("Watchman started [%s]", VERSION) # resources = hass.data["lovelace"]["resources"] # await resources.async_get_info() # for itm in resources.async_items(): # _LOGGER.debug(itm) return True async def update_listener(hass: HomeAssistant, entry: ConfigEntry): """Reload integration when options changed""" await hass.config_entries.async_reload(entry.entry_id) async def async_unload_entry( hass: HomeAssistant, config_entry ): # pylint: disable=unused-argument """Handle integration unload""" for cancel_handle in hass.data[DOMAIN].get("cancel_handlers", []): if cancel_handle: cancel_handle() if hass.services.has_service(DOMAIN, "report"): hass.services.async_remove(DOMAIN, "report") unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) if DOMAIN_DATA in hass.data: hass.data.pop(DOMAIN_DATA) if DOMAIN in hass.data: hass.data.pop(DOMAIN) if unload_ok: _LOGGER.info("Watchman integration successfully unloaded.") else: _LOGGER.error("Having trouble unloading watchman integration") return unload_ok async def add_services(hass: HomeAssistant): """adds report service""" async def async_handle_report(call): """Handle the service call""" config = hass.data.get(DOMAIN_DATA, {}) path = get_report_path(hass, config.get(CONF_REPORT_PATH, None)) send_notification = call.data.get(CONF_SEND_NOTIFICATION, False) create_file = call.data.get(CONF_CREATE_FILE, True) test_mode = call.data.get(CONF_TEST_MODE, False) # validate service params for param in call.data: if param not in CONF_ALLOWED_SERVICE_PARAMS: await async_notification( hass, "Watchman error", f"Unknown service " f"parameter: `{param}`.", error=True, ) if not (send_notification or create_file): message = ( "Either `send_nofification` or `create_file` should be set to `true` " "in service parameters." ) await async_notification(hass, "Watchman error", message, error=True) if call.data.get(CONF_PARSE_CONFIG, False): parse_config(hass, reason="service call") if send_notification: chunk_size = call.data.get(CONF_CHUNK_SIZE, config.get(CONF_CHUNK_SIZE)) service = call.data.get(CONF_SERVICE_NAME, None) service_data = call.data.get(CONF_SERVICE_DATA, None) if service_data and not service: await async_notification( hass, "Watchman error", "Missing `service` parameter. The `data` parameter can only be used " "in conjunction with `service` parameter.", error=True, ) if onboarding(hass, service, path): await async_notification( hass, "🖖 Achievement unlocked: first report!", f"Your first watchman report was stored in `{path}` \n\n " "TIP: set `service` parameter in configuration.yaml file to " "receive report via notification service of choice. \n\n " "This is one-time message, it will not bother you in the future.", ) else: await async_report_to_notification( hass, service, service_data, chunk_size ) if create_file: try: await async_report_to_file(hass, path, test_mode=test_mode) except OSError as exception: await async_notification( hass, "Watchman error", f"Unable to write report: {exception}", error=True, ) hass.services.async_register(DOMAIN, "report", async_handle_report) async def add_event_handlers(hass: HomeAssistant): """add event handlers""" async def async_schedule_refresh_states(hass, delay): """schedule refresh of the sensors state""" now = dt_util.utcnow() next_interval = now + timedelta(seconds=delay) async_track_point_in_utc_time(hass, async_delayed_refresh_states, next_interval) async def async_delayed_refresh_states(timedate): # pylint: disable=unused-argument """refresh sensors state""" # parse_config should be invoked beforehand coordinator = hass.data[DOMAIN]["coordinator"] await coordinator.async_refresh() async def async_on_home_assistant_started(event): # pylint: disable=unused-argument parse_config(hass, reason="HA restart") startup_delay = get_config(hass, CONF_STARTUP_DELAY, 0) await async_schedule_refresh_states(hass, startup_delay) async def async_on_configuration_changed(event): typ = event.event_type if typ == EVENT_CALL_SERVICE: domain = event.data.get("domain", None) service = event.data.get("service", None) if domain in TRACKED_EVENT_DOMAINS and service in [ "reload_core_config", "reload", ]: parse_config(hass, reason="configuration changes") coordinator = hass.data[DOMAIN]["coordinator"] await coordinator.async_refresh() elif typ in [EVENT_AUTOMATION_RELOADED, EVENT_SCENE_RELOADED]: parse_config(hass, reason="configuration changes") coordinator = hass.data[DOMAIN]["coordinator"] await coordinator.async_refresh() async def async_on_service_changed(event): service = f"{event.data['domain']}.{event.data['service']}" if service in hass.data[DOMAIN].get("service_list", []): _LOGGER.debug("Monitored service changed: %s", service) coordinator = hass.data[DOMAIN]["coordinator"] await coordinator.async_refresh() async def async_on_state_changed(event): """refresh monitored entities on state change""" def state_or_missing(state_id): """return missing state if entity not found""" return "missing" if not event.data[state_id] else event.data[state_id].state if event.data["entity_id"] in hass.data[DOMAIN].get("entity_list", []): ignored_states = get_config(hass, CONF_IGNORED_STATES, []) old_state = state_or_missing("old_state") new_state = state_or_missing("new_state") checked_states = set(MONITORED_STATES) - set(ignored_states) if new_state in checked_states or old_state in checked_states: _LOGGER.debug("Monitored entity changed: %s", event.data["entity_id"]) coordinator = hass.data[DOMAIN]["coordinator"] await coordinator.async_refresh() # hass is not started yet, schedule config parsing once it loaded if not hass.is_running: hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, async_on_home_assistant_started ) hdlr = [] hdlr.append( hass.bus.async_listen(EVENT_CALL_SERVICE, async_on_configuration_changed) ) hdlr.append( hass.bus.async_listen(EVENT_AUTOMATION_RELOADED, async_on_configuration_changed) ) hdlr.append( hass.bus.async_listen(EVENT_SCENE_RELOADED, async_on_configuration_changed) ) hdlr.append( hass.bus.async_listen(EVENT_SERVICE_REGISTERED, async_on_service_changed) ) hdlr.append(hass.bus.async_listen(EVENT_SERVICE_REMOVED, async_on_service_changed)) hdlr.append(hass.bus.async_listen(EVENT_STATE_CHANGED, async_on_state_changed)) hass.data[DOMAIN]["cancel_handlers"] = hdlr def parse_config(hass: HomeAssistant, reason=None): """parse home assistant configuration files""" assert hass.data.get(DOMAIN_DATA) start_time = time.time() included_folders = get_included_folders(hass) ignored_files = hass.data[DOMAIN_DATA].get(CONF_IGNORED_FILES, None) entity_list, service_list, files_parsed, files_ignored = parse( hass, included_folders, ignored_files, hass.config.config_dir ) hass.data[DOMAIN]["entity_list"] = entity_list hass.data[DOMAIN]["service_list"] = service_list hass.data[DOMAIN]["files_parsed"] = files_parsed hass.data[DOMAIN]["files_ignored"] = files_ignored hass.data[DOMAIN]["parse_duration"] = time.time() - start_time _LOGGER.info( "%s files parsed and %s files ignored in %.2fs. due to %s", files_parsed, files_ignored, hass.data[DOMAIN]["parse_duration"], reason, ) def get_included_folders(hass): """gather the list of folders to parse""" folders = [] config_folders = [hass.config.config_dir] if DOMAIN_DATA in hass.data: config_folders = hass.data[DOMAIN_DATA].get("included_folders") if not config_folders: config_folders = [hass.config.config_dir] for fld in config_folders: folders.append(os.path.join(fld, "**/*.yaml")) if DOMAIN_DATA in hass.data and hass.data[DOMAIN_DATA].get(CONF_CHECK_LOVELACE): folders.append(os.path.join(hass.config.config_dir, ".storage/**/lovelace*")) return folders async def async_report_to_file(hass, path, test_mode): """save report to a file""" coordinator = hass.data[DOMAIN]["coordinator"] await coordinator.async_refresh() report_chunks = report(hass, table_renderer, chunk_size=0, test_mode=test_mode) # OSError exception is handled in async_handle_report with open(path, "w", encoding="utf-8") as report_file: for chunk in report_chunks: report_file.write(chunk) async def async_report_to_notification(hass, service_str, service_data, chunk_size): """send report via notification service""" if not service_str: service_str = get_config(hass, CONF_SERVICE_NAME, None) service_data = get_config(hass, CONF_SERVICE_DATA2, None) if not service_str: await async_notification( hass, "Watchman Error", "You should specify `service` parameter (in integration options or as `service` " "parameter) in order to send report via notification", ) return if not is_service(hass, service_str): await async_notification( hass, "Watchman Error", f"{service_str} is not a valid service for notification", ) domain = service_str.split(".")[0] service = ".".join(service_str.split(".")[1:]) data = {} if service_data is None else json.loads(service_data) coordinator = hass.data[DOMAIN]["coordinator"] await coordinator.async_refresh() report_chunks = report(hass, text_renderer, chunk_size) for chunk in report_chunks: data["message"] = chunk # blocking=True ensures execution order if not await hass.services.async_call(domain, service, data, blocking=True): _LOGGER.error( "Unable to call service %s.%s due to an error.", domain, service ) break async def async_notification(hass, title, message, error=False, n_id="watchman"): """Show a persistent notification""" persistent_notification.async_create( hass, message, title=title, notification_id=n_id, ) if error: raise HomeAssistantError(message.replace("`", "")) def onboarding(hass, service, path): """check if the user runs report for the first time""" service = service or get_config(hass, CONF_SERVICE_NAME, None) return not (service or os.path.exists(path))