Files
homeassistant_config/config/custom_components/watchman/utils.py
2024-08-09 06:45:02 +02:00

405 lines
15 KiB
Python

"""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