Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
@@ -0,0 +1 @@
"""Initialize utils."""
@@ -0,0 +1,8 @@
"""Custom logger for HACS."""
import logging
from ..const import PACKAGE_NAME
_LOGGER: logging.Logger = logging.getLogger(PACKAGE_NAME)
INDENT = " "
@@ -0,0 +1,81 @@
import re
"""Constants for the parser."""
# file extensions supported by parser
# .json is not parsed as they typically contains unrelevant false positive entries
YAML_FILE_EXTS = {'.yaml', '.yml'}
JSON_FILE_EXTS = {'.config_entries'}
# .storage folder is ignored completely if in _IGNORED_DIRS
# but whitelisted files in .storage are detected dynamically by the parser
STORAGE_WHITELIST_PATTERNS = {'core.config_entries', 'lovelace*'}
MAX_FILE_SIZE = 500 * 1024 # 500 KB
# A fallback list of Home Assistant entity platforms for the CLI parser.
# This list is used ONLY when the 'homeassistant' library is not importable.
# If the library is present, this list is overwritten by the official constants.
# DO NOT EXTEND this list for missing integration domains; use HA_DOMAINS instead.
PLATFORMS = [
"ai_task", "air_quality", "alarm_control_panel", "assist_satellite", "binary_sensor", "button",
"calendar", "camera", "climate", "conversation", "cover", "date", "datetime", "device_tracker",
"event", "fan", "geo_location", "humidifier", "image", "image_processing", "lawn_mower",
"light", "lock", "media_player", "notify", "number", "remote", "scene", "select", "sensor",
"siren", "stt", "switch", "text", "time", "todo", "tts", "update", "vacuum", "valve",
"wake_word", "water_heater", "weather"
]
# Integration domains used as a fallback for the standalone CLI parser.
# In runtime, this list is merged with `hass.config.components`.
# It includes standard domains (e.g., 'automation') and common integrations for testing.
HA_DOMAINS = [
"automation", "script", "group", "zone", "person", "sun", "input_boolean", "input_button",
"input_datetime", "input_number", "input_select", "input_text", "timer", "counter",
"shell_command", "persistent_notification", "homeassistant", "system_log", "logger",
"recorder", "history", "logbook", "map", "mobile_app", "tag", "webhook", "websocket_api",
"ble_monitor", "hassio", "mqtt", "python_script", "speedtestdotnet", "telegram_bot",
"xiaomi_miio", "yeelight", "alert", "plant", "proximity", "schedule", "template"
]
# following patterns are ignored by watchman as they are neither entities, nor actions
BUNDLED_IGNORED_ITEMS = [
"timer.cancelled", "timer.finished", "timer.started", "timer.restarted",
"timer.paused", "event.*", "date.*", "time.*", "map.*", "homeassistant.*"
]
# Path which includes this string is considered as ESPHome folder
ESPHOME_PATH_SEGMENT = "esphome"
# Allowed keys for ESPHome files to be considered as HA entities/services
ESPHOME_ALLOWED_KEYS = {'service', 'action', 'entity_id'}
# Keys which values (whole hierarchy) should be ignored
IGNORED_BRANCH_KEYS = {'url', 'example', 'description', 'event_type', 'logger'}
# Keys identifying an action/service call
ACTION_KEYS = {'service', 'action', 'service_template', 'perform_action'}
# Keys where the parser ignores the immediate string value (to avoid false positives)
# but continues recursion if the value is a complex structure
IGNORED_VALUE_KEYS = {'trigger', 'triggers'}
# Domains to parse in core.config_entries
CONFIG_ENTRY_DOMAINS = {'group', 'template'}
# Directories to skip during recursive scan
IGNORED_DIRS = {'.git', '__pycache__', '.venv', 'venv', 'deps', 'backups', 'custom_components', '.cache', '.esphome', '.storage', 'tmp', 'blueprints', 'media', 'share', 'www', 'trash'}
# Regex building blocks for entity detection
# Forbidden prefixes: letters, numbers, _, ., /, \, @, $, %, &, |, -
REGEX_ENTITY_BOUNDARY = r"(?:^|[^a-zA-Z0-9_./\\@$%&|-])"
REGEX_OPTIONAL_STATES = r"(?:states\.)?"
REGEX_ENTITY_SUFFIX = r"\.[a-z0-9_]+"
REGEX_STRICT_SERVICE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$", re.IGNORECASE)
File diff suppressed because it is too large Load Diff
+327
View File
@@ -0,0 +1,327 @@
"""Reporting function of Watchman."""
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from textwrap import wrap
import time
from typing import Any
from prettytable import PrettyTable
import pytz
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from ..const import (
CONF_ACTION_NAME,
CONF_COLUMNS_WIDTH,
CONF_FRIENDLY_NAMES,
CONF_HEADER,
COORD_DATA_IGNORED_FILES,
COORD_DATA_PROCESSED_FILES,
DEFAULT_HEADER,
REPORT_ENTRY_TYPE_ENTITY,
REPORT_ENTRY_TYPE_SERVICE,
)
from .logger import _LOGGER
from .utils import get_config, get_entity_state, get_entry, is_action
async def parsing_stats(hass: HomeAssistant, start_time: float) -> tuple[str, float, float, float]:
"""Separate func for test mocking."""
def get_timezone(hass: HomeAssistant) -> Any:
return pytz.timezone(hass.config.time_zone)
timezone = await hass.async_add_executor_job(get_timezone, hass)
coordinator = get_entry(hass).runtime_data.coordinator
parse_duration = await coordinator.async_get_last_parse_duration()
return (
datetime.now(timezone).strftime("%d %b %Y %H:%M:%S"),
parse_duration,
coordinator.last_check_duration,
time.time() - start_time,
)
async def report(
hass: HomeAssistant,
*,
render: Callable[[HomeAssistant, str, dict[str, Any], dict[str, Any]], str]
| None = None,
chunk_size: int | None = None,
parse_config: bool | None = None,
) -> list[str]:
"""Generate a report of missing entities and services."""
from ..const import CONF_EXCLUDE_DISABLED_AUTOMATION
from ..coordinator import renew_missing_items_list
start_time = time.time()
entry = get_entry(hass)
coordinator = entry.runtime_data.coordinator
if parse_config:
coordinator.request_parser_rescan(reason="service call")
# OPTIMIZATION: One-Pass Data Retrieval
all_items = await coordinator.hub.async_get_all_items()
service_list = all_items["services"]
entity_list = all_items["entities"]
# Build filter context once
ctx = coordinator._build_filter_context()
missing_services = renew_missing_items_list(
hass,
service_list,
ctx,
item_type="action",
)
missing_entities = renew_missing_items_list(
hass,
entity_list,
ctx,
item_type="entity",
)
header = get_config(hass, CONF_HEADER, DEFAULT_HEADER)
files_parsed = coordinator.data.get(COORD_DATA_PROCESSED_FILES, 0)
files_ignored = coordinator.data.get(COORD_DATA_IGNORED_FILES, 0)
rep = f"{header} \n"
if missing_services:
rep += f"\n-== Missing {len(missing_services)} action(s) from "
rep += f"{len(service_list)} found in your config:\n"
if render:
rep += render(hass, REPORT_ENTRY_TYPE_SERVICE, missing_services, service_list)
rep += "\n"
elif len(service_list) > 0:
rep += f"\n-== Congratulations, all {len(service_list)} actions from "
rep += "your config are available!\n"
else:
rep += "\n-== No actions found in configuration files!\n"
if missing_entities:
rep += f"\n-== Missing {len(missing_entities)} entity(ies) from "
rep += f"{len(entity_list)} found in your config:\n"
if render:
rep += render(hass, REPORT_ENTRY_TYPE_ENTITY, missing_entities, 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"
(
report_datetime,
parse_duration,
check_duration,
render_duration,
) = await parsing_stats(hass, start_time)
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 = ""
chunk_size = chunk_size or 0
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
def table_renderer(
hass: HomeAssistant,
entry_type: str,
missing_items: dict[str, Any],
parsed_list: dict[str, Any],
) -> str:
"""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 == REPORT_ENTRY_TYPE_SERVICE:
table.field_names = ["Action ID", "State", "Location"]
for service in missing_items:
row = [
fill(service, columns_width[0]),
fill("missing", columns_width[1]),
format_occurrences(parsed_list[service]["occurrences"], columns_width[2]),
]
table.add_row(row)
table.align = "l"
return table.get_string()
if entry_type == REPORT_ENTRY_TYPE_ENTITY:
friendly_names = get_config(hass, CONF_FRIENDLY_NAMES, False)
header = ["Entity ID", "State", "Location"]
table.field_names = header
for entity in missing_items:
state, name = get_entity_state(hass, entity, friendly_names=friendly_names)
table.add_row(
[
fill(entity, columns_width[0], name),
fill(state, columns_width[1]),
format_occurrences(parsed_list[entity]["occurrences"], columns_width[2]),
]
)
table.align = "l"
return table.get_string()
return f"Table render error: unknown entry type: {entry_type}"
def text_renderer(
hass: HomeAssistant,
entry_type: str,
missing_items: dict[str, Any],
parsed_list: dict[str, Any],
) -> str:
"""Render plain lists in the report."""
result = ""
if entry_type == REPORT_ENTRY_TYPE_SERVICE:
for service in missing_items:
loc = format_occurrences(parsed_list[service]["occurrences"], 0)
result += f"{service} in {loc}\n"
return result
if entry_type == REPORT_ENTRY_TYPE_ENTITY:
friendly_names = get_config(hass, CONF_FRIENDLY_NAMES, False)
for entity in missing_items:
state, name = get_entity_state(hass, entity, friendly_names=friendly_names)
entity_col = entity if not name else f"{entity} ('{name}')"
loc = format_occurrences(parsed_list[entity]["occurrences"], 0)
result += f"{entity_col} [{state}] in: {loc}\n"
return result
return f"Text render error: unknown entry type: {entry_type}"
def format_occurrences(occurrences: list[dict[str, Any]], width: int) -> str:
"""Format occurrence locations, handling UI helpers gracefully."""
helpers = set()
files = {}
for occ in occurrences:
context = occ.get("context")
path = occ["path"]
line = occ["line"]
# Check for UI Helper
if context and context.get("parent_type", "").startswith("helper_"):
p_type = context["parent_type"].replace("helper_", "").capitalize()
alias = context.get("parent_alias") or "Unknown"
emoji = ""
if p_type == "Group":
emoji = "👥"
elif p_type == "Template":
emoji = "🧩"
helpers.add(f'{emoji} {p_type}: "{alias}"')
else:
# Standard File
if path not in files:
files[path] = []
files[path].append(str(line))
lines = sorted(helpers)
for path, line_numer_list in files.items():
lines.append(f"📄 {path}:{','.join(line_numer_list)}")
out = "\n".join(lines)
if width > 0:
wrapped_lines = []
for line in out.split("\n"):
wrapped_lines.extend(wrap(line, width))
return "\n".join([line.ljust(width) for line in wrapped_lines])
return out
def fill(data: Any, width: int, extra: str | None = None) -> str:
"""Arrange data by table column width."""
if data and isinstance(data, dict):
lines = []
for key, val in data.items():
lines.append(f"{key}:{','.join([str(v) for v in val])}")
out = "\n".join(lines)
else:
out = str(data) if not extra else f"{data} ('{extra}')"
if width > 0:
wrapped_lines = []
for line in out.split("\n"):
wrapped_lines.extend(wrap(line, width))
return "\n".join([line.ljust(width) for line in wrapped_lines])
return out
def get_columns_width(user_width: list[int] | None) -> list[int]:
"""Define width of the report columns."""
default_width = [30, 7, 60]
if not user_width:
return default_width
try:
return [max(user_width[i], 7) 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
async def async_report_to_file(hass: HomeAssistant, path: str) -> None:
"""Save report to a file."""
report_chunks = await report(hass, render=table_renderer, chunk_size=0)
def write(path: str) -> None:
with Path(path).open("w", encoding="utf-8") as report_file:
report_file.writelines(report_chunks)
await hass.async_add_executor_job(write, path)
_LOGGER.debug(f"Report saved to {path}")
async def async_report_to_notification(
hass: HomeAssistant, action_str: str, service_data: dict[str, Any], chunk_size: int
) -> None:
"""Send report via notification action."""
if not action_str:
raise HomeAssistantError(f"Missing `{CONF_ACTION_NAME}` parameter.")
if action_str and not isinstance(action_str, str):
raise HomeAssistantError(
f"`action` parameter should be a string, got {action_str}"
)
if not is_action(hass, action_str):
raise HomeAssistantError(f"{action_str} is not a valid action for notification")
domain = action_str.split(".", maxsplit=1)[0]
action = ".".join(action_str.split(".")[1:])
data = {} if service_data is None else service_data.copy()
if "notification_id" not in data:
data["notification_id"] = "watchman_report"
_LOGGER.debug(f"SERVICE_DATA {data}")
report_chunks = await report(hass, render=text_renderer, chunk_size=chunk_size)
for msg_chunk in report_chunks:
data["message"] = msg_chunk
# blocking=True ensures send order
await hass.services.async_call(domain, action, data, blocking=True)
+245
View File
@@ -0,0 +1,245 @@
"""Miscellaneous support functions for Watchman."""
from collections.abc import AsyncGenerator
import fnmatch
import os
import re
from types import MappingProxyType
from typing import Any
import anyio
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import entity_registry as er
from ..const import (
CONF_COLUMNS_WIDTH,
CONF_EXCLUDE_DISABLED_AUTOMATION,
CONF_FRIENDLY_NAMES,
CONF_HEADER,
CONF_IGNORED_FILES,
CONF_IGNORED_ITEMS,
CONF_IGNORED_STATES,
CONF_INCLUDED_FOLDERS,
CONF_LOG_OBFUSCATE,
CONF_REPORT_PATH,
CONF_SECTION_APPEARANCE_LOCATION,
CONF_STARTUP_DELAY,
DEFAULT_OPTIONS,
DOMAIN_DATA,
)
from .logger import _LOGGER, INDENT
_OBFUSCATE_ENABLED = True
def set_obfuscation_config(enabled: bool) -> None:
"""Set the global obfuscation enabled state."""
global _OBFUSCATE_ENABLED
_OBFUSCATE_ENABLED = enabled
def get_val(
options: MappingProxyType[str, Any], key: str, section: str | None = None
) -> Any:
"""Return value of a key."""
val = None
if section:
try:
val = options[section][key]
except KeyError:
_LOGGER.error(
"Key %s is missing in secion %s, return default value", key, section
)
val = DEFAULT_OPTIONS[section][key]
else:
val = options.get(key, DEFAULT_OPTIONS[key])
return val
def to_lists(
options: MappingProxyType[str, Any] | dict[str, Any], key: str, section: str | None = None
) -> list[str]:
"""Transform configuration value to the list of strings."""
val = get_val(options, key, section)
if isinstance(val, list):
return val
if not val:
return []
return [x.strip() for x in val.split(",") if x.strip()]
def to_listi(
options: MappingProxyType[str, Any] | dict[str, Any], key: str, section: str | None = None
) -> list[int]:
"""Transform configuration value to the list of integers."""
val = get_val(options, key, section)
return [int(x) for x in val.split(",") if x.strip()]
def get_entry(hass: HomeAssistant) -> Any:
"""Return Watchman's ConfigEntry instance."""
if DOMAIN_DATA not in hass.data:
return None
return hass.config_entries.async_get_entry(
hass.data[DOMAIN_DATA]["config_entry_id"]
)
def get_config(hass: HomeAssistant, key: str, default: Any | None = None) -> Any: # noqa: PLR0911
"""Get configuration value from ConfigEntry."""
if DOMAIN_DATA not in hass.data:
return default
entry = hass.config_entries.async_get_entry(
hass.data[DOMAIN_DATA]["config_entry_id"]
)
if not isinstance(entry, ConfigEntry):
return default
if key in [
CONF_INCLUDED_FOLDERS,
CONF_IGNORED_ITEMS,
CONF_IGNORED_FILES,
]:
return to_lists(entry.data, key)
if key in [
CONF_IGNORED_STATES,
CONF_EXCLUDE_DISABLED_AUTOMATION,
CONF_STARTUP_DELAY,
CONF_LOG_OBFUSCATE,
]:
return get_val(entry.data, key)
if key in [CONF_HEADER, CONF_REPORT_PATH, CONF_COLUMNS_WIDTH, CONF_FRIENDLY_NAMES]:
section_name = CONF_SECTION_APPEARANCE_LOCATION
if key == CONF_COLUMNS_WIDTH:
return to_listi(entry.data, CONF_COLUMNS_WIDTH, section_name)
return get_val(entry.data, key, section_name)
return default
async def async_is_valid_path(path: str) -> bool:
"""Validate the report path."""
folder, f_name = os.path.split(path)
if is_valid := (
folder.strip() and f_name.strip() and await anyio.Path(folder).exists()
):
is_valid = not await anyio.Path(path).is_dir()
return is_valid
async def async_get_next_file(
folder_tuples: list[tuple[str, str]], ignored_files: list[str]
) -> AsyncGenerator[tuple[str, bool]]:
"""Return next file from scan queue."""
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(
f"{INDENT}Scan folder {folder_name} with pattern {glob_pattern} for configuration files"
)
async for filename in anyio.Path(folder_name).glob(glob_pattern):
yield (
str(filename),
(ignored_files and ignored_files_re.match(str(filename))),
)
def get_included_folders(hass: HomeAssistant) -> list[tuple[str, str]]:
"""Gather the list of folders to parse."""
folders = []
included = get_config(hass, CONF_INCLUDED_FOLDERS, None)
if not included:
# Default to config dir if nothing specified
folders.append((hass.config.config_dir, "**"))
else:
for fld in included:
folders.append((fld, "**"))
return folders
def is_action(hass: HomeAssistant, entry: str) -> bool:
"""Check whether config entry is an action."""
if not isinstance(entry, str):
return False
try:
domain, service = split_entity_id(entry)
except ValueError:
return False
return bool(service) and hass.services.has_service(domain, service)
def get_entity_state(
hass: HomeAssistant,
entry: str,
*,
friendly_names: bool = False,
registry_entry: er.RegistryEntry | None = None,
) -> tuple[str, str | None]:
"""Return entity state or 'missing' if entity does not exist."""
entity_state = hass.states.get(entry)
name = None
if entity_state and entity_state.attributes.get("friendly_name", None):
if friendly_names:
name = entity_state.name
if not entity_state:
state = "missing"
if registry_entry is None:
entity_registry = er.async_get(hass)
registry_entry = entity_registry.async_get(entry)
if registry_entry and registry_entry.disabled_by:
state = "disabled"
else:
state = str(entity_state.state).replace("unavailable", "unavail")
if split_entity_id(entry)[0] == "input_button" and state == "unknown":
state = "available"
return state, name
def obfuscate_id(item_id: Any) -> Any:
"""Obfuscate entity or action ID for logging."""
if not _OBFUSCATE_ENABLED:
return item_id
if isinstance(item_id, (list, tuple, set)):
return ", ".join([str(obfuscate_id(x)) for x in item_id])
if not isinstance(item_id, str) or "." not in item_id:
return item_id
parts = item_id.split(".", 1)
domain = parts[0]
name = parts[1]
if len(name) <= 3:
return f"{domain}.{name}"
if len(name) > 15:
# Truncate to 15 chars: 3 visible + 11 stars + '~'
return f"{domain}.{name[:3]}***********~"
prefix = name[:3]
suffix = name[3:]
masked_suffix = ""
for char in suffix:
if char.isalnum():
masked_suffix += "*"
else:
masked_suffix += char
return f"{domain}.{prefix}{masked_suffix}"
@@ -0,0 +1,71 @@
"""YAML Loader for Watchman."""
from typing import Any, Self
import yaml
# Custom YAML Loader with Line Numbers
class StringWithLine(str):
"""String subclass that holds the line number, tag info, and scalar style."""
def __new__(
cls, value: str, line: int, *, is_tag: bool = False, style: str | None = None
) -> Self:
obj = str.__new__(cls, value)
obj.line = line
obj.is_tag = is_tag
obj.style = style # Store the style (e.g., '"', "'", '>', '|')
return obj
class LineLoader(yaml.SafeLoader):
"""Custom YAML loader that attaches line numbers to scalars."""
def construct_scalar(self, node: yaml.ScalarNode) -> Any:
value = super().construct_scalar(node)
if isinstance(value, str):
# Pass node.style to the string object
return StringWithLine(value, node.start_mark.line + 1, style=node.style)
return value
def flatten_mapping(self, node: yaml.MappingNode) -> None:
"""Override flatten_mapping to handle merge keys ('<<') safely."""
merge = []
index = 0
while index < len(node.value):
key_node, value_node = node.value[index]
if key_node.tag == 'tag:yaml.org,2002:merge':
del node.value[index]
if isinstance(value_node, yaml.MappingNode):
self.flatten_mapping(value_node)
merge.extend(value_node.value)
elif isinstance(value_node, yaml.SequenceNode):
submerge = []
for subnode in value_node.value:
if isinstance(subnode, yaml.MappingNode):
self.flatten_mapping(subnode)
submerge.append(subnode)
elif isinstance(subnode, yaml.ScalarNode):
continue
for subnode in reversed(submerge):
merge.extend(subnode.value)
elif isinstance(value_node, yaml.ScalarNode):
continue
elif key_node.tag == 'tag:yaml.org,2002:value':
key_node.tag = 'tag:yaml.org,2002:str'
index += 1
else:
index += 1
if merge:
node.value = merge + node.value
LineLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG, LineLoader.construct_scalar)
# Handle custom HA tags by ignoring them or treating as string
def default_ctor(loader: yaml.Loader, tag_suffix: str, node: yaml.ScalarNode) -> Any:
value = loader.construct_scalar(node)
if isinstance(value, str):
# Pass node.style here as well
return StringWithLine(value, node.start_mark.line + 1, is_tag=True, style=node.style)
return value
yaml.add_multi_constructor('!', default_ctor, Loader=LineLoader)