"""Miscellaneous support functions for watchman""" import anyio import re import fnmatch import time import logging from datetime import datetime from textwrap import wrap import os from typing import Any import pytz from prettytable import PrettyTable from homeassistant.exceptions import HomeAssistantError from homeassistant.core import HomeAssistant from .const import ( DOMAIN, DOMAIN_DATA, DEFAULT_HEADER, DEFAULT_CHUNK_SIZE, CONF_HEADER, CONF_IGNORED_ITEMS, CONF_IGNORED_STATES, CONF_CHUNK_SIZE, CONF_COLUMNS_WIDTH, CONF_FRIENDLY_NAMES, BUNDLED_IGNORED_ITEMS, DEFAULT_REPORT_FILENAME, ) _LOGGER = logging.getLogger(__name__) async def read_file(hass: HomeAssistant, path: str) -> Any: """Read a file.""" def read(): with open(hass.config.path(path), "r", encoding="utf-8") as open_file: return open_file.read() return await hass.async_add_executor_job(read) async def write_file(hass: HomeAssistant, path: str, content: Any) -> None: """Write a file.""" def write(): with open(hass.config.path(path), "w", encoding="utf-8") as open_file: open_file.write(content) await hass.async_add_executor_job(write) def get_config(hass: HomeAssistant, key, default): """get configuration value""" if DOMAIN_DATA not in hass.data: return default return hass.data[DOMAIN_DATA].get(key, default) async def async_get_report_path(hass, path): """if path not specified, create report in config directory with default filename""" if not path: path = hass.config.path(DEFAULT_REPORT_FILENAME) folder, _ = os.path.split(path) if not await anyio.Path(folder).exists(): raise HomeAssistantError(f"Incorrect report_path: {path}.") return path def get_columns_width(user_width): """define width of the report columns""" default_width = [30, 7, 60] if not user_width: return default_width try: return [7 if user_width[i] < 7 else user_width[i] for i in range(3)] except (TypeError, IndexError): _LOGGER.error( "Invalid configuration for table column widths, default values" " used %s", default_width, ) return default_width def table_renderer(hass, entry_type): """Render ASCII tables in the report""" table = PrettyTable() columns_width = get_config(hass, CONF_COLUMNS_WIDTH, None) columns_width = get_columns_width(columns_width) if entry_type == "service_list": services_missing = hass.data[DOMAIN]["services_missing"] service_list = hass.data[DOMAIN]["service_list"] table.field_names = ["Service ID", "State", "Location"] for service in services_missing: row = [ fill(service, columns_width[0]), fill("missing", columns_width[1]), fill(service_list[service], columns_width[2]), ] table.add_row(row) table.align = "l" return table.get_string() elif entry_type == "entity_list": entities_missing = hass.data[DOMAIN]["entities_missing"] entity_list = hass.data[DOMAIN]["entity_list"] friendly_names = get_config(hass, CONF_FRIENDLY_NAMES, False) header = ["Entity ID", "State", "Location"] table.field_names = header for entity in entities_missing: state, name = get_entity_state(hass, entity, friendly_names) table.add_row( [ fill(entity, columns_width[0], name), fill(state, columns_width[1]), fill(entity_list[entity], columns_width[2]), ] ) table.align = "l" return table.get_string() else: return f"Table render error: unknown entry type: {entry_type}" def text_renderer(hass, entry_type): """Render plain lists in the report""" result = "" if entry_type == "service_list": services_missing = hass.data[DOMAIN]["services_missing"] service_list = hass.data[DOMAIN]["service_list"] for service in services_missing: result += f"{service} in {fill(service_list[service], 0)}\n" return result elif entry_type == "entity_list": entities_missing = hass.data[DOMAIN]["entities_missing"] entity_list = hass.data[DOMAIN]["entity_list"] friendly_names = get_config(hass, CONF_FRIENDLY_NAMES, False) for entity in entities_missing: state, name = get_entity_state(hass, entity, friendly_names) entity_col = entity if not name else f"{entity} ('{name}')" result += f"{entity_col} [{state}] in: {fill(entity_list[entity], 0)}\n" return result else: return f"Text render error: unknown entry type: {entry_type}" async def async_get_next_file(folder_tuples, ignored_files): """Returns next file for scan""" if not ignored_files: ignored_files = "" else: ignored_files = "|".join([f"({fnmatch.translate(f)})" for f in ignored_files]) ignored_files_re = re.compile(ignored_files) for folder_name, glob_pattern in folder_tuples: _LOGGER.debug( "Scan folder %s with pattern %s for configuration files", folder_name, glob_pattern, ) async for filename in anyio.Path(folder_name).glob(glob_pattern): _LOGGER.debug("Found file %s.", filename) yield ( str(filename), (ignored_files and ignored_files_re.match(str(filename))), ) def add_entry(_list, entry, yaml_file, lineno): """Add entry to list of missing entities/services with line number information""" _LOGGER.debug("Added %s to the list", entry) if entry in _list: if yaml_file in _list[entry]: _list[entry].get(yaml_file, []).append(lineno) else: _list[entry] = {yaml_file: [lineno]} def is_service(hass, entry): """check whether config entry is a service""" domain, service = entry.split(".")[0], ".".join(entry.split(".")[1:]) return hass.services.has_service(domain, service) def get_entity_state(hass, entry, friendly_names=False): """returns entity state or missing if entity does not extst""" entity = hass.states.get(entry) name = None if entity and entity.attributes.get("friendly_name", None): if friendly_names: name = entity.name # fix for #75, some integrations return non-string states state = ( "missing" if not entity else str(entity.state).replace("unavailable", "unavail") ) return state, name def check_services(hass): """check if entries from config file are services""" services_missing = {} if "missing" in get_config(hass, CONF_IGNORED_STATES, []): return services_missing if DOMAIN not in hass.data or "service_list" not in hass.data[DOMAIN]: raise HomeAssistantError("Service list not found") service_list = hass.data[DOMAIN]["service_list"] _LOGGER.debug("::check_services") for entry, occurences in service_list.items(): if not is_service(hass, entry): services_missing[entry] = occurences _LOGGER.debug("service %s added to missing list", entry) return services_missing def check_entitites(hass): """check if entries from config file are entities with an active state""" ignored_states = [ "unavail" if s == "unavailable" else s for s in get_config(hass, CONF_IGNORED_STATES, []) ] if DOMAIN not in hass.data or "entity_list" not in hass.data[DOMAIN]: _LOGGER.error("Entity list not found") raise Exception("Entity list not found") entity_list = hass.data[DOMAIN]["entity_list"] entities_missing = {} _LOGGER.debug("::check_entities") for entry, occurences in entity_list.items(): if is_service(hass, entry): # this is a service, not entity _LOGGER.debug("entry %s is service, skipping", entry) continue state, _ = get_entity_state(hass, entry) if state in ignored_states: _LOGGER.debug("entry %s ignored due to ignored_states", entry) continue if state in ["missing", "unknown", "unavail"]: entities_missing[entry] = occurences _LOGGER.debug("entry %s added to missing list", entry) return entities_missing async def parse(hass, folders, ignored_files, root=None): """Parse a yaml or json file for entities/services""" files_parsed = 0 entity_pattern = re.compile( r"(?:(?<=\s)|(?<=^)|(?<=\")|(?<=\'))([A-Za-z_0-9]*\s*:)?(?:\s*)?(?:states.)?" r"((air_quality|alarm_control_panel|alert|automation|binary_sensor|button|calendar|camera|" r"climate|counter|device_tracker|fan|group|humidifier|input_boolean|input_datetime|" r"input_number|input_select|light|lock|media_player|number|person|plant|proximity|remote|" r"scene|script|select|sensor|sun|switch|timer|vacuum|weather|zone)\.[A-Za-z_*0-9]+)" ) service_pattern = re.compile(r"service:\s*([A-Za-z_0-9]*\.[A-Za-z_0-9]+)") comment_pattern = re.compile(r"#.*") entity_list = {} service_list = {} effectively_ignored = [] _LOGGER.debug("::parse started") async for yaml_file, ignored in async_get_next_file(folders, ignored_files): short_path = os.path.relpath(yaml_file, root) if ignored: effectively_ignored.append(short_path) _LOGGER.debug("%s ignored", yaml_file) continue try: lineno = 1 async with await anyio.open_file( yaml_file, mode="r", encoding="utf-8" ) as f: async for line in f: line = re.sub(comment_pattern, "", line) for match in re.finditer(entity_pattern, line): typ, val = match.group(1), match.group(2) if ( typ != "service:" and "*" not in val and not val.endswith(".yaml") ): add_entry(entity_list, val, short_path, lineno) for match in re.finditer(service_pattern, line): val = match.group(1) add_entry(service_list, val, short_path, lineno) lineno += 1 files_parsed += 1 _LOGGER.debug("%s parsed", yaml_file) except OSError as exception: _LOGGER.error("Unable to parse %s: %s", yaml_file, exception) except UnicodeDecodeError as exception: _LOGGER.error( "Unable to parse %s: %s. Use UTF-8 encoding to avoid this error", yaml_file, exception, ) # remove ignored entities and services from resulting lists ignored_items = get_config(hass, CONF_IGNORED_ITEMS, []) ignored_items = list(set(ignored_items + BUNDLED_IGNORED_ITEMS)) excluded_entities = [] excluded_services = [] for itm in ignored_items: if itm: excluded_entities.extend(fnmatch.filter(entity_list, itm)) excluded_services.extend(fnmatch.filter(service_list, itm)) entity_list = {k: v for k, v in entity_list.items() if k not in excluded_entities} service_list = {k: v for k, v in service_list.items() if k not in excluded_services} _LOGGER.debug("Parsed files: %s", files_parsed) _LOGGER.debug("Ignored files: %s", effectively_ignored) _LOGGER.debug("Found entities: %s", len(entity_list)) _LOGGER.debug("Found services: %s", len(service_list)) return (entity_list, service_list, files_parsed, len(effectively_ignored)) def fill(data, width, extra=None): """arrange data by table column width""" if data and isinstance(data, dict): key, val = next(iter(data.items())) out = f"{key}:{','.join([str(v) for v in val])}" else: out = str(data) if not extra else f"{data} ('{extra}')" return ( "\n".join([out.ljust(width) for out in wrap(out, width)]) if width > 0 else out ) async def report(hass, render, chunk_size, test_mode=False): """generates watchman report either as a table or as a list""" if DOMAIN not in hass.data: raise HomeAssistantError("No data for report, refresh required.") start_time = time.time() header = get_config(hass, CONF_HEADER, DEFAULT_HEADER) services_missing = hass.data[DOMAIN]["services_missing"] service_list = hass.data[DOMAIN]["service_list"] entities_missing = hass.data[DOMAIN]["entities_missing"] entity_list = hass.data[DOMAIN]["entity_list"] files_parsed = hass.data[DOMAIN]["files_parsed"] files_ignored = hass.data[DOMAIN]["files_ignored"] chunk_size = ( get_config(hass, CONF_CHUNK_SIZE, DEFAULT_CHUNK_SIZE) if chunk_size is None else chunk_size ) rep = f"{header} \n" if services_missing: rep += f"\n-== Missing {len(services_missing)} service(s) from " rep += f"{len(service_list)} found in your config:\n" rep += render(hass, "service_list") rep += "\n" elif len(service_list) > 0: rep += f"\n-== Congratulations, all {len(service_list)} services from " rep += "your config are available!\n" else: rep += "\n-== No services found in configuration files!\n" if entities_missing: rep += f"\n-== Missing {len(entities_missing)} entity(ies) from " rep += f"{len(entity_list)} found in your config:\n" rep += render(hass, "entity_list") rep += "\n" elif len(entity_list) > 0: rep += f"\n-== Congratulations, all {len(entity_list)} entities from " rep += "your config are available!\n" else: rep += "\n-== No entities found in configuration files!\n" def get_timezone(hass): return pytz.timezone(hass.config.time_zone) timezone = await hass.async_add_executor_job(get_timezone, hass) if not test_mode: report_datetime = datetime.now(timezone).strftime("%d %b %Y %H:%M:%S") parse_duration = hass.data[DOMAIN]["parse_duration"] check_duration = hass.data[DOMAIN]["check_duration"] render_duration = time.time() - start_time else: report_datetime = "01 Jan 1970 00:00:00" parse_duration = 0.01 check_duration = 0.105 render_duration = 0.0003 rep += f"\n-== Report created on {report_datetime}\n" rep += ( f"-== Parsed {files_parsed} files in {parse_duration:.2f}s., " f"ignored {files_ignored} files \n" ) rep += f"-== Generated in: {render_duration:.2f}s. Validated in: {check_duration:.2f}s." report_chunks = [] chunk = "" for line in iter(rep.splitlines()): chunk += f"{line}\n" if chunk_size > 0 and len(chunk) > chunk_size: report_chunks.append(chunk) chunk = "" if chunk: report_chunks.append(chunk) return report_chunks