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
+355
View File
@@ -0,0 +1,355 @@
"""The Watchman integration."""
from dataclasses import dataclass
from pathlib import Path
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
EVENT_HOMEASSISTANT_STARTED,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.loader import async_get_integration
from .const import (
CONF_COLUMNS_WIDTH,
CONF_FRIENDLY_NAMES,
CONF_HEADER,
CONF_IGNORED_FILES,
CONF_IGNORED_ITEMS,
CONF_IGNORED_STATES,
CONF_LOG_OBFUSCATE,
CONF_REPORT_PATH,
CONF_SECTION_APPEARANCE_LOCATION,
CONF_STARTUP_DELAY,
CONFIG_ENTRY_MINOR_VERSION,
CONFIG_ENTRY_VERSION,
CURRENT_DB_SCHEMA_VERSION,
DEFAULT_DELAY,
DEFAULT_OPTIONS,
DEFAULT_REPORT_FILENAME,
DOMAIN,
DOMAIN_DATA,
LOCK_FILENAME,
PLATFORMS,
REPORT_SERVICE_NAME,
STATE_SAFE_MODE,
STATE_WAITING_HA,
STORAGE_VERSION,
)
from .coordinator import WatchmanCoordinator
from .hub import WatchmanHub
from .services import WatchmanServicesSetup
from .utils.logger import _LOGGER
from .utils.utils import get_config, set_obfuscation_config
type WMConfigEntry = ConfigEntry[WMData]
@dataclass
class WMData:
"""Watchman runtime data."""
coordinator: WatchmanCoordinator
hub: WatchmanHub
async def async_setup_entry(hass: HomeAssistant, config_entry: WMConfigEntry) -> bool:
"""Set up this integration using UI."""
from .const import DB_FILENAME, LEGACY_DB_FILENAME
legacy_db_path = Path(hass.config.path(".storage", LEGACY_DB_FILENAME))
db_path = Path(hass.config.path(".storage", DB_FILENAME))
# One-time migration: rename watchman.db to watchman_v2.db
if not db_path.exists() and legacy_db_path.exists():
_LOGGER.info(
"Migrating legacy database %s to %s", LEGACY_DB_FILENAME, DB_FILENAME
)
legacy_db_path.rename(db_path)
integration = await async_get_integration(hass, DOMAIN)
# Configure obfuscation
set_obfuscation_config(config_entry.data.get(CONF_LOG_OBFUSCATE, True))
hub = WatchmanHub(hass, str(db_path))
coordinator = WatchmanCoordinator(
hass,
_LOGGER,
config_entry=config_entry,
hub=hub,
version=str(integration.version),
)
await coordinator.async_load_stats()
config_entry.runtime_data = WMData(coordinator, hub)
async def async_on_home_assistant_started(event: Event | None) -> None: # pylint: disable=unused-argument
"""Update watchman sensors and start listening to HA events when Home Assistant started."""
# Guard Clause: Check if integration is still loaded
if DOMAIN_DATA not in hass.data or hass.data[DOMAIN_DATA].get("config_entry_id") != config_entry.entry_id:
_LOGGER.debug("Skipping async_on_home_assistant_started: Integration unloaded.")
return
# prime the coordinator with cached data immediately to minimize startup delay
try:
_LOGGER.debug("HA is ready. Prime coordinator with cached data.")
all_items = await hub.async_get_all_items()
parsed_entities = all_items["entities"]
parsed_services = all_items["services"]
initial_data = await coordinator.async_process_parsed_data(
parsed_entities, parsed_services
)
coordinator.async_set_updated_data(initial_data)
except Exception as e:
_LOGGER.error(f"Failed to prime coordinator with cached data: {e}")
if coordinator.safe_mode:
_LOGGER.info(
"Watchman is in Safe Mode. Skipping event subscriptions and initial scan."
)
return
coordinator.subscribe_to_events(config_entry)
_LOGGER.debug("Subscribed to HA events.")
if event:
# integration started during HA startup
# use startup delay to schedule initial parsing (usually longer)
startup_delay = get_config(hass, CONF_STARTUP_DELAY, 0)
else:
# intergation started after installation from Devices&Services
# use short delay to schedule initial parsing (usually longer)
startup_delay = DEFAULT_DELAY
_LOGGER.debug(
f"Executing mandatory startup scan in: {startup_delay}s."
)
coordinator.request_parser_rescan(reason="integration reload", delay=startup_delay)
_LOGGER.info(
"Watchman integration started [%s], DB: %s, Stats: %s",
coordinator.version,
CURRENT_DB_SCHEMA_VERSION,
STORAGE_VERSION,
)
# Check for previous crash
lock_path = Path(hass.config.path(".storage", LOCK_FILENAME))
def check_crash() -> bool:
if lock_path.exists():
_LOGGER.error(
"Previous crash detected (lock file found). Watchman is starting in Safe Mode."
)
lock_path.unlink(missing_ok=True)
return True
return False
if await hass.async_add_executor_job(check_crash):
coordinator.update_status(STATE_SAFE_MODE)
hass.data[DOMAIN_DATA] = {"config_entry_id": config_entry.entry_id}
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
config_entry.async_on_unload(config_entry.add_update_listener(update_listener))
WatchmanServicesSetup(hass, config_entry)
if hass.is_running:
# HA is already up and running, don't need to wait until it is booted
_LOGGER.debug("Home assistant is up, proceed with async_on_home_assistant_started")
await async_on_home_assistant_started(None)
else:
# integration started during HA startup, wait until it is fully loaded
_LOGGER.debug("Waiting for Home Assistant to be up and running...")
if not coordinator.safe_mode:
config_entry.runtime_data.coordinator.update_status(STATE_WAITING_HA)
# do not use async_listen_once here to make unsubscribe callback valid even after the event fires,
# preventing the "unknown job listener" warning on entry unload.
unsub = hass.bus.async_listen(EVENT_HOMEASSISTANT_STARTED, async_on_home_assistant_started)
config_entry.async_on_unload(unsub)
return True
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload integration when options changed."""
set_obfuscation_config(entry.data.get(CONF_LOG_OBFUSCATE, True))
if hasattr(entry, "runtime_data") and entry.runtime_data:
_LOGGER.debug("Invalidating FilterContext cache due to update Watchman config_entry data")
entry.runtime_data.coordinator.invalidate_filter_context()
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, config_entry: WMConfigEntry) -> bool: # pylint: disable=unused-argument
"""Handle integration unload."""
if hasattr(config_entry, "runtime_data") and config_entry.runtime_data:
await config_entry.runtime_data.coordinator.async_shutdown()
if hass.services.has_service(DOMAIN, REPORT_SERVICE_NAME):
hass.services.async_remove(DOMAIN, REPORT_SERVICE_NAME)
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 async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate ConfigEntry persistent data to a new version."""
if config_entry.version > CONFIG_ENTRY_VERSION:
# the user has downgraded from a future version
_LOGGER.error(
"Unable to migrate Watchman entry from version %d.%d. If integration version was downgraded, either reinstall or use backup to restore its data.",
config_entry.version,
config_entry.minor_version,
)
return False
if config_entry.version == 1:
# migrate from ConfigEntry.options to ConfigEntry.data
_LOGGER.info(
"Start Watchman configuration entry migration to version 2. Source data: %s",
config_entry.options,
)
data = DEFAULT_OPTIONS
data[CONF_IGNORED_STATES] = config_entry.options.get(CONF_IGNORED_STATES, [])
if CONF_IGNORED_ITEMS in config_entry.options:
data[CONF_IGNORED_ITEMS] = ",".join(
str(x) for x in config_entry.options[CONF_IGNORED_ITEMS]
)
if CONF_IGNORED_FILES in config_entry.options:
data[CONF_IGNORED_FILES] = ",".join(
str(x) for x in config_entry.options[CONF_IGNORED_FILES]
)
if CONF_FRIENDLY_NAMES in config_entry.options:
data[CONF_SECTION_APPEARANCE_LOCATION][CONF_FRIENDLY_NAMES] = (
config_entry.options[CONF_FRIENDLY_NAMES]
)
data[CONF_SECTION_APPEARANCE_LOCATION][CONF_REPORT_PATH] = (
config_entry.options.get(
CONF_REPORT_PATH, hass.config.path(DEFAULT_REPORT_FILENAME)
)
)
if CONF_HEADER in config_entry.options:
data[CONF_SECTION_APPEARANCE_LOCATION][CONF_HEADER] = config_entry.options[
CONF_HEADER
]
if CONF_COLUMNS_WIDTH in config_entry.options:
data[CONF_SECTION_APPEARANCE_LOCATION][CONF_COLUMNS_WIDTH] = ",".join(
str(x) for x in config_entry.options[CONF_COLUMNS_WIDTH]
)
if CONF_STARTUP_DELAY in config_entry.options:
data[CONF_STARTUP_DELAY] = config_entry.options[CONF_STARTUP_DELAY]
_LOGGER.info(
"Successfully migrated Watchman configuration entry from version %d.%d. to version %d.%d",
config_entry.version,
config_entry.minor_version,
CONFIG_ENTRY_VERSION,
CONFIG_ENTRY_MINOR_VERSION,
)
hass.config_entries.async_update_entry(
config_entry,
data=data,
options={},
minor_version=CONFIG_ENTRY_MINOR_VERSION,
version=CONFIG_ENTRY_VERSION,
)
return True
if config_entry.version == CONFIG_ENTRY_VERSION:
data = {**config_entry.data}
current_minor = config_entry.minor_version
# Sequential migration logic for minor versions
if current_minor < 2:
_LOGGER.info("Migrating Watchman entry to minor version 2")
# Enforce minimum startup delay
current_delay = data.get(CONF_STARTUP_DELAY, 0)
min_delay = DEFAULT_OPTIONS[CONF_STARTUP_DELAY]
if current_delay < min_delay:
_LOGGER.info(
"Enforcing minimum startup delay of %ss (was %ss)",
min_delay,
current_delay,
)
data[CONF_STARTUP_DELAY] = min_delay
current_minor = 2
if current_minor < 3:
_LOGGER.info("Migrating Watchman entry to minor version 3")
# Default to True (enabled)
data[CONF_LOG_OBFUSCATE] = DEFAULT_OPTIONS.get(CONF_LOG_OBFUSCATE, True)
current_minor = 3
if current_minor < 4:
_LOGGER.info("Migrating Watchman entry to minor version 4")
# Do not initialize ignored_labels here to allow text entity to restore state lazily
current_minor = 4
if current_minor != config_entry.minor_version:
hass.config_entries.async_update_entry(
config_entry,
data=data,
minor_version=current_minor,
version=CONFIG_ENTRY_VERSION,
)
_LOGGER.info(
"Successfully migrated Watchman configuration entry to version %d.%d",
config_entry.version,
current_minor,
)
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle removal of an entry."""
from .const import DB_FILENAME, LEGACY_DB_FILENAME, LOCK_FILENAME, STORAGE_KEY
db_path = hass.config.path(".storage", DB_FILENAME)
journal_path = f"{db_path}-journal"
lock_path = hass.config.path(".storage", LOCK_FILENAME)
stats_path = hass.config.path(".storage", STORAGE_KEY)
# Legacy files
legacy_db_path = hass.config.path(".storage", LEGACY_DB_FILENAME)
legacy_wal_path = f"{legacy_db_path}-wal"
legacy_shm_path = f"{legacy_db_path}-shm"
def remove_files() -> None:
Path(db_path).unlink(missing_ok=True)
Path(journal_path).unlink(missing_ok=True)
Path(lock_path).unlink(missing_ok=True)
Path(stats_path).unlink(missing_ok=True)
# Cleanup legacy
Path(legacy_db_path).unlink(missing_ok=True)
Path(legacy_wal_path).unlink(missing_ok=True)
Path(legacy_shm_path).unlink(missing_ok=True)
await hass.async_add_executor_job(remove_files)
_LOGGER.info("Watchman database file removed: %s", db_path)
_LOGGER.info("Watchman journal file removed: %s", journal_path)
_LOGGER.info("Watchman stats file removed: %s", stats_path)
+60
View File
@@ -0,0 +1,60 @@
"""Button entity for Watchman."""
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import CONF_REPORT_PATH, DOMAIN, REPORT_SERVICE_NAME
from .utils.utils import get_config
class WatchmanReportButton(ButtonEntity):
"""Button entity to trigger Watchman report."""
_attr_has_entity_name = True
_attr_translation_key = "create_report_file"
_attr_entity_category = EntityCategory.CONFIG
_attr_icon = "mdi:file-document-outline"
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the entity."""
self.hass = hass
self._attr_unique_id = f"{DOMAIN}_report_button"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, "watchman_unique_id")},
manufacturer="dummylabs",
model="Watchman",
name="Watchman",
sw_version=config_entry.runtime_data.coordinator.version,
entry_type=DeviceEntryType.SERVICE,
configuration_url="https://github.com/dummylabs/thewatchman",
)
async def async_press(self) -> None:
"""Handle the button press."""
await self.hass.services.async_call(
DOMAIN,
REPORT_SERVICE_NAME,
{"parse_config": True},
blocking=True
)
report_path = get_config(self.hass, CONF_REPORT_PATH)
await self.hass.services.async_call(
"persistent_notification",
"create",
{
"title": "🛡️Watchman",
"message": f"Watchman Report is ready: {report_path}"
}
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the button platform."""
async_add_entities([WatchmanReportButton(hass, config_entry)])
+234
View File
@@ -0,0 +1,234 @@
"""ConfigFlow definition for Watchman."""
from types import MappingProxyType
from typing import Any
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, selector
from .const import (
CONF_COLUMNS_WIDTH,
CONF_EXCLUDE_DISABLED_AUTOMATION,
CONF_FRIENDLY_NAMES,
CONF_HEADER,
CONF_IGNORED_FILES,
CONF_IGNORED_ITEMS,
CONF_IGNORED_LABELS,
CONF_IGNORED_STATES,
CONF_LOG_OBFUSCATE,
CONF_REPORT_PATH,
CONF_SECTION_APPEARANCE_LOCATION,
CONF_STARTUP_DELAY,
CONFIG_ENTRY_MINOR_VERSION,
CONFIG_ENTRY_VERSION,
DEFAULT_OPTIONS,
DEFAULT_REPORT_FILENAME,
DOMAIN,
MONITORED_STATES,
)
from .utils.logger import _LOGGER
from .utils.utils import async_is_valid_path, get_val
INCLUDED_FOLDERS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
IGNORED_ITEMS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
IGNORED_STATES_SCHEMA = vol.Schema(MONITORED_STATES)
IGNORED_FILES_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
COLUMNS_WIDTH_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.positive_int]))
def _get_data_schema() -> vol.Schema:
select = selector.TextSelector(selector.TextSelectorConfig(multiline=True))
return vol.Schema(
{
vol.Optional(
CONF_IGNORED_ITEMS,
): select,
vol.Optional(
CONF_IGNORED_STATES,
): cv.multi_select(MONITORED_STATES),
vol.Optional(
CONF_IGNORED_FILES,
): select,
vol.Optional(
CONF_IGNORED_LABELS,
): selector.LabelSelector(
selector.LabelSelectorConfig(multiple=True)
),
vol.Required(
CONF_STARTUP_DELAY,
): cv.positive_int,
vol.Optional(
CONF_EXCLUDE_DISABLED_AUTOMATION,
): cv.boolean,
vol.Optional(
CONF_LOG_OBFUSCATE,
): cv.boolean,
vol.Required(CONF_SECTION_APPEARANCE_LOCATION): data_entry_flow.section(
vol.Schema(
{
vol.Required(
CONF_REPORT_PATH,
): cv.string,
vol.Required(
CONF_HEADER,
): cv.string,
vol.Required(
CONF_COLUMNS_WIDTH,
): cv.string,
vol.Optional(
CONF_FRIENDLY_NAMES,
): cv.boolean,
}
),
{"collapsed": True},
),
}
)
async def _async_validate_input(
hass: HomeAssistant,
user_input: dict[str, Any],
) -> tuple[MappingProxyType[str, str], MappingProxyType[str, str]]:
errors: dict[str, str] = {}
placeholders: dict[str, str] = {}
columns_width = get_val(
user_input, CONF_COLUMNS_WIDTH, CONF_SECTION_APPEARANCE_LOCATION
)
if columns_width:
try:
columns_width = [int(x) for x in columns_width.split(",") if x.strip()]
if len(columns_width) != 3:
raise ValueError
columns_width = COLUMNS_WIDTH_SCHEMA(columns_width)
except (ValueError, vol.Invalid):
errors["base"] = "invalid_columns_width"
if (
CONF_SECTION_APPEARANCE_LOCATION in user_input
and CONF_REPORT_PATH in user_input[CONF_SECTION_APPEARANCE_LOCATION]
):
report_path = user_input[CONF_SECTION_APPEARANCE_LOCATION][CONF_REPORT_PATH]
if not await async_is_valid_path(report_path):
errors["base"] = "invalid_report_path"
return (
MappingProxyType[str, str](errors),
MappingProxyType[str, str](placeholders),
)
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow used to set up new instance of integration."""
VERSION = CONFIG_ENTRY_VERSION
MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Create new Watchman entry via UI."""
_LOGGER.debug("::async_step_user::")
options = DEFAULT_OPTIONS
options[CONF_SECTION_APPEARANCE_LOCATION][CONF_REPORT_PATH] = (
self.hass.config.path(DEFAULT_REPORT_FILENAME)
)
options[CONF_IGNORED_FILES] = DEFAULT_OPTIONS[CONF_IGNORED_FILES]
return self.async_create_entry(title="Watchman", data=options)
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Get the options flow for this handler."""
return OptionsFlowHandler()
class OptionsFlowHandler(OptionsFlow):
"""Options flow used to change configuration (options) of existing instance of integration."""
async def async_get_key_in_section(
self, data: dict[str, Any], key: str, section: str | None = None
) -> Any:
"""Return value of a key in ConfigEntry.data."""
if section:
if section in data:
return section[data].get(key, None)
else:
return data.get(key)
return None
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options form.
This method is invoked twice.
1. To populate form with default values (user_input=None)
2. To validate values entered by user (user_imput = {user_data})
If no errors found, it should return creates_entry
"""
if user_input is not None: # we asked to validate values entered by user
_LOGGER.debug("OptionsFlowHandler.async_step_init")
_LOGGER.debug(f"user_input= {user_input}")
errors, placeholders = await _async_validate_input(self.hass, user_input)
if not errors:
# if user cleared up `ignored files` or `ignored items` form fields
# user_input dict dict will not contain these keys, so we add them explicitly
if (
CONF_IGNORED_FILES in self.config_entry.data
and CONF_IGNORED_FILES not in user_input
):
user_input[CONF_IGNORED_FILES] = ""
if (
CONF_IGNORED_ITEMS in self.config_entry.data
and CONF_IGNORED_ITEMS not in user_input
):
user_input[CONF_IGNORED_ITEMS] = ""
# see met.no code, without update_entry the EXISTING entry
# will not be updated with user input, but entry.options will do
self.hass.config_entries.async_update_entry(
self.config_entry, data={**self.config_entry.data, **user_input}
)
return self.async_create_entry(title="", data={})
# in case of errors in user_input, display them in the form
# use previous user input as suggested values
_LOGGER.debug(
"::OptionsFlowHandler.async_step_init:: validation results errors:[%s] placehoders:[%s]",
errors,
placeholders,
)
placeholders = dict(placeholders)
placeholders["url"] = "https://github.com/dummylabs/thewatchman#configuration"
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
_get_data_schema(),
user_input,
),
errors=dict(errors),
description_placeholders=dict(placeholders),
)
# we asked to provide default values for the form
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
_get_data_schema(),
self.config_entry.data,
),
description_placeholders={
"url": "https://github.com/dummylabs/thewatchman#configuration"
},
)
+147
View File
@@ -0,0 +1,147 @@
"""Definition of constants."""
from homeassistant.components.automation import EVENT_AUTOMATION_RELOADED
from homeassistant.components.homeassistant import (
SERVICE_RELOAD_ALL,
SERVICE_RELOAD_CORE_CONFIG,
SERVICE_RELOAD_CUSTOM_TEMPLATES,
)
from homeassistant.components.homeassistant.scene import EVENT_SCENE_RELOADED
from homeassistant.const import SERVICE_RELOAD, Platform
DOMAIN = "watchman"
DOMAIN_DATA = f"{DOMAIN}_data"
CONFIG_ENTRY_VERSION = 2
CONFIG_ENTRY_MINOR_VERSION = 4
DEFAULT_REPORT_FILENAME = f"{DOMAIN}_report.txt"
DB_FILENAME = f"{DOMAIN}_v2.db"
LEGACY_DB_FILENAME = f"{DOMAIN}.db"
CURRENT_DB_SCHEMA_VERSION = 7
STORAGE_KEY = f"{DOMAIN}.stats"
STORAGE_VERSION = 1
LOCK_FILENAME = f"{DOMAIN}.lock"
DEFAULT_HEADER = "-== WATCHMAN REPORT ==- "
DEFAULT_CHUNK_SIZE = 3500
DB_TIMEOUT = 5
# parsing runs at most once per interval.
PARSE_COOLDOWN = 60
# delay before start parsing
DEFAULT_DELAY = 10
PACKAGE_NAME = f"custom_components.{DOMAIN}"
REPORT_SERVICE_NAME = "report"
LABELS_SERVICE_NAME = "set_ignored_labels"
HASS_DATA_CANCEL_HANDLERS = "cancel_handlers"
COORD_DATA_MISSING_ENTITIES = "entities_missing"
COORD_DATA_MISSING_ACTIONS = "services_missing"
COORD_DATA_LAST_UPDATE = "last_update"
COORD_DATA_SERVICE_ATTRS = "service_attrs"
COORD_DATA_ENTITY_ATTRS = "entity_attrs"
COORD_DATA_PARSE_DURATION = "parse_duration"
COORD_DATA_LAST_PARSE = "last_parse"
COORD_DATA_PROCESSED_FILES = "processed_files"
COORD_DATA_IGNORED_FILES = "ignored_files"
REPORT_ENTRY_TYPE_SERVICE = "service_list"
REPORT_ENTRY_TYPE_ENTITY = "entity_list"
CONF_IGNORED_FILES = "ignored_files"
CONF_HEADER = "report_header"
CONF_REPORT_PATH = "report_path"
CONF_IGNORED_ITEMS = "ignored_items"
CONF_SERVICE_NAME = "service"
CONF_ACTION_NAME = "action"
CONF_SERVICE_DATA = "data"
CONF_SERVICE_DATA2 = "service_data"
CONF_INCLUDED_FOLDERS = "included_folders"
CONF_EXCLUDE_DISABLED_AUTOMATION = "exclude_disabled_automation"
CONF_IGNORED_STATES = "ignored_states"
CONF_CHUNK_SIZE = "chunk_size"
CONF_CREATE_FILE = "create_file"
CONF_SEND_NOTIFICATION = "send_notification"
CONF_PARSE_CONFIG = "parse_config"
CONF_COLUMNS_WIDTH = "columns_width"
CONF_STARTUP_DELAY = "startup_delay"
CONF_FRIENDLY_NAMES = "friendly_names"
CONF_LOG_OBFUSCATE = "log_obfuscate"
CONF_IGNORED_LABELS = "ignored_labels"
# configuration parameters allowed in watchman.report service data
CONF_ALLOWED_SERVICE_PARAMS = [
CONF_SERVICE_NAME,
CONF_ACTION_NAME,
CONF_CHUNK_SIZE,
CONF_CREATE_FILE,
CONF_SEND_NOTIFICATION,
CONF_PARSE_CONFIG,
CONF_SERVICE_DATA,
]
CONF_SECTION_APPEARANCE_LOCATION = "appearance_location_options"
CONF_SECTION_NOTIFY_ACTION = "notify_action_options"
# events and service calls monitored by Watchman
# these events indicate that configuration files
# changed on disk that requires re-parsing
WATCHED_EVENTS =[
EVENT_AUTOMATION_RELOADED,
EVENT_SCENE_RELOADED
]
WATCHED_SERVICES = [
SERVICE_RELOAD_CORE_CONFIG,
SERVICE_RELOAD,
SERVICE_RELOAD_ALL,
SERVICE_RELOAD_CUSTOM_TEMPLATES
]
SENSOR_LAST_UPDATE = "last_updated"
SENSOR_MISSING_ENTITIES = "missing_entities"
SENSOR_MISSING_ACTIONS = "missing_actions"
SENSOR_STATUS = "status"
SENSOR_PARSE_DURATION = "parse_duration"
SENSOR_LAST_PARSE = "last_parse"
SENSOR_PROCESSED_FILES = "processed_files"
SENSOR_IGNORED_FILES = "ignored_files"
MONITORED_STATES = ["unavailable", "unknown", "missing", "disabled"]
STATE_WAITING_HA = "waiting_for_ha"
STATE_PARSING = "parsing"
STATE_PENDING = "pending"
STATE_IDLE = "idle"
STATE_SAFE_MODE = "safe_mode"
BUNDLED_IGNORED_ITEMS = [
"timer.cancelled",
"timer.finished",
"timer.started",
"timer.restarted",
"timer.paused",
"event.*",
"date.*",
]
# Platforms
PLATFORMS = [Platform.SENSOR, Platform.TEXT, Platform.BUTTON]
DEFAULT_OPTIONS = {
CONF_IGNORED_ITEMS: "",
CONF_IGNORED_STATES: [],
CONF_EXCLUDE_DISABLED_AUTOMATION: True,
CONF_IGNORED_FILES: "",
CONF_STARTUP_DELAY: 30,
CONF_LOG_OBFUSCATE: True,
CONF_SECTION_APPEARANCE_LOCATION: {
CONF_HEADER: "-== Watchman Report ==-",
CONF_REPORT_PATH: "",
CONF_COLUMNS_WIDTH: "30, 8, 60",
CONF_FRIENDLY_NAMES: False,
},
}
+991
View File
@@ -0,0 +1,991 @@
import asyncio
from collections.abc import Iterable
import contextlib
from dataclasses import dataclass
import logging
from pathlib import Path
import time
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .hub import WatchmanHub
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
EVENT_CALL_SERVICE,
EVENT_SERVICE_REGISTERED,
EVENT_SERVICE_REMOVED,
)
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import (
CONF_EXCLUDE_DISABLED_AUTOMATION,
CONF_IGNORED_FILES,
CONF_IGNORED_LABELS,
CONF_IGNORED_STATES,
COORD_DATA_ENTITY_ATTRS,
COORD_DATA_IGNORED_FILES,
COORD_DATA_LAST_PARSE,
COORD_DATA_LAST_UPDATE,
COORD_DATA_MISSING_ACTIONS,
COORD_DATA_MISSING_ENTITIES,
COORD_DATA_PARSE_DURATION,
COORD_DATA_PROCESSED_FILES,
COORD_DATA_SERVICE_ATTRS,
DEFAULT_DELAY,
EVENT_AUTOMATION_RELOADED,
EVENT_SCENE_RELOADED,
LOCK_FILENAME,
MONITORED_STATES,
PARSE_COOLDOWN,
STATE_IDLE,
STATE_PARSING,
STATE_PENDING,
STATE_SAFE_MODE,
STATE_WAITING_HA,
STORAGE_KEY,
STORAGE_VERSION,
WATCHED_EVENTS,
WATCHED_SERVICES,
)
from .utils.logger import _LOGGER, INDENT
from .utils.parser_core import ParseResult
from .utils.report import fill
from .utils.utils import (
get_config,
get_entity_state,
is_action,
obfuscate_id,
)
parser_lock = asyncio.Lock()
@dataclass
class FilterContext:
"""Context object holding data for filtering missing items."""
entity_registry: er.EntityRegistry
disabled_automations: set[str]
automation_map: dict[str, str]
ignored_states: set[str]
ignored_labels: set[str]
exclude_disabled: bool
def _resolve_automations(
hass: HomeAssistant,
raw_automations: Iterable[str],
automation_map: dict[str, str],
ent_reg: er.EntityRegistry,
) -> set[str]:
"""Resolve parser parent IDs to Home Assistant entity IDs."""
automations = set()
for p_id in raw_automations:
# 1. Automation Unique ID match
if p_id in automation_map:
automations.add(automation_map[p_id])
continue
# 2. Script ID match (by key)
script_id = f"script.{p_id}"
if hass.states.get(script_id) or ent_reg.async_get(script_id):
automations.add(script_id)
continue
# 3. Fallback
automations.add(p_id)
return automations
def _is_safe_to_report(
hass: HomeAssistant,
entry: str,
data: dict[str, Any],
ctx: FilterContext,
is_entity_check: bool
) -> bool:
"""Check context (automations) to decide if item should be reported.
Returns True if item should be reported, False if it is excluded.
"""
occurrences = data["locations"]
raw_automations = data["automations"]
automations = _resolve_automations(hass, raw_automations, ctx.automation_map, ctx.entity_registry)
if ctx.exclude_disabled and automations:
all_parents_disabled = True
for parent_id in automations:
if parent_id not in ctx.disabled_automations:
all_parents_disabled = False
break
if all_parents_disabled:
return False
if is_entity_check and automations:
auto_id = next(iter(automations))
if not hass.states.get(auto_id):
reg_entry = ctx.entity_registry.async_get(auto_id)
if not (reg_entry and reg_entry.disabled_by):
_LOGGER.warning(
f"? Unable to locate automation: {obfuscate_id(auto_id)} for {obfuscate_id(entry)}. "
f"Occurrences: {occurrences}"
)
return True
def _is_available(state: Any) -> bool:
"""Check if state is available/active.
Missing/Unavailable: None, "unavailable", "unknown", "missing"
Active: Any other state
"""
if state is None:
return False
val = state.state if hasattr(state, "state") else str(state)
return val not in ("unavailable", "unknown", "missing", "None")
def check_single_entity_status( # noqa: PLR0911
hass: HomeAssistant,
entry: str,
data: dict[str, Any],
ctx: FilterContext,
item_type: str,
) -> list[dict[str, Any]] | None:
"""Check status of a single entity with cross-validation logic.
Returns occurrences list if missing/invalid, None otherwise.
"""
is_entity_check = item_type == "entity"
# reg_entry used for: disabled check, label filtering, and cross-check logic.
reg_entry = None
# --- PHASE 1: STATUS RESOLUTION ---
if is_entity_check:
# fetch reg_entry to re-use below in code
reg_entry = ctx.entity_registry.async_get(entry)
current_state, _ = get_entity_state(hass, entry, registry_entry=reg_entry)
# Fast exit for healthy entities
if current_state not in ("missing", "unknown", "unavail", "disabled"):
return None
# Cross-validation: If missing, check if it's actually an action
if is_action(hass, entry):
return None
else: # item_type == "action"
if is_action(hass, entry):
return None
# Cross-validation: If missing, check if it's actually an entity.
# Check 1: State Machine
# Check 2: Registry
# fetch reg_entry to re-use below in code
reg_entry = ctx.entity_registry.async_get(entry)
if hass.states.get(entry) or reg_entry:
return None
# --- PHASE 2: CONFIGURATION FILTERS ---
# 2. Check Ignored Labels (Applies to BOTH entities and actions)
# Use the pre-fetched reg_entry
if ctx.ignored_labels and reg_entry and hasattr(reg_entry, "labels") and \
not ctx.ignored_labels.isdisjoint(reg_entry.labels):
return None
# --- PHASE 3: CONTEXT ANALYSIS ---
# Expensive checks (parsing automations) only if everything else failed
if not _is_safe_to_report(hass, entry, data, ctx, is_entity_check):
return None
return data["occurrences"]
def renew_missing_items_list(
hass: HomeAssistant,
parsed_list: dict[str, Any],
ctx: FilterContext,
item_type: str,
) -> dict[str, Any]:
"""Refresh list of missing items using the provided FilterContext."""
missing_items = {}
is_entity = item_type == "entity"
# Specific check for actions if 'missing' is ignored
if not is_entity and "missing" in ctx.ignored_states:
_LOGGER.info("MISSING state set as ignored in config, so watchman ignores missing actions.")
return missing_items
for entry, data in parsed_list.items():
result = check_single_entity_status(hass, entry, data, ctx, item_type)
if result is not None:
missing_items[entry] = result
return missing_items
class WatchmanCoordinator(DataUpdateCoordinator):
"""Watchman coordinator."""
def __init__(
self,
hass: HomeAssistant,
logger: logging.Logger,
config_entry: ConfigEntry,
hub: "WatchmanHub",
version: str,
) -> None:
"""Initialize watchmman coordinator."""
debouncer = Debouncer(
hass,
_LOGGER,
cooldown=5.0,
immediate=False
)
super().__init__(
hass,
_LOGGER,
name=config_entry.title.lower(), # Name of the data. For logging purposes.
config_entry=config_entry,
always_update=False,
request_refresh_debouncer=debouncer
)
self.hass = hass
self.hub = hub
self.debouncer = debouncer
self.last_check_duration = 0.0
self.checked_states = set()
self._status = STATE_WAITING_HA
self._needs_parse = False
self._parse_task: asyncio.Task | None = None
self._cooldown_unsub = None
self._delay_unsub = None
self._unsub_state_listener: CALLBACK_TYPE | None = None
self._unsub_automation_listener: CALLBACK_TYPE | None = None
self._last_parse_time = 0.0
self._current_delay = 0
self._version = version
self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
self._filter_context_cache: FilterContext | None = None
# Optimization: Dirty set tracking
self._dirty_entities: set[str] = set()
self._missing_entities_cache: dict[str, Any] = {}
self._missing_actions_cache: dict[str, Any] = {}
self._force_full_rescan: bool = True
self.data = {
COORD_DATA_MISSING_ENTITIES: 0,
COORD_DATA_MISSING_ACTIONS: 0,
COORD_DATA_LAST_UPDATE: dt_util.now(),
COORD_DATA_SERVICE_ATTRS: "",
COORD_DATA_ENTITY_ATTRS: "",
COORD_DATA_PARSE_DURATION: 0.0,
COORD_DATA_LAST_PARSE: None,
COORD_DATA_PROCESSED_FILES: 0,
COORD_DATA_IGNORED_FILES: 0,
}
def invalidate_filter_context(self) -> None:
"""Invalidate the cached filter context."""
self._filter_context_cache = None
# Invalidate filter context implies global rules changed, so force full rescan
self._force_full_rescan = True
def _build_filter_context(self) -> FilterContext:
"""Build the context object for filtering operations."""
if self._filter_context_cache:
return self._filter_context_cache
_LOGGER.debug("Build FilterContext object for filtering operations")
ent_reg = er.async_get(self.hass)
exclude_disabled = get_config(self.hass, CONF_EXCLUDE_DISABLED_AUTOMATION, False)
ignored_states = get_config(self.hass, CONF_IGNORED_STATES, [])
ignored_labels = set(self.config_entry.data.get(CONF_IGNORED_LABELS, []))
automation_map = {}
disabled_automations = set()
# 1. Registry Pass: Map unique_id and check disabled_by
for entry in ent_reg.entities.values():
if entry.domain != "automation":
continue
# Map unique_id to entity_id
automation_map[entry.unique_id] = entry.entity_id
if exclude_disabled and entry.disabled_by:
disabled_automations.add(entry.entity_id)
num_disabled_auto = len(disabled_automations)
num_off_auto = 0
# 2. State Pass: Check for 'off' state (covers both registry and non-registry automations)
if exclude_disabled:
for state in self.hass.states.async_all("automation"):
if state.state == "off":
num_off_auto += 1
disabled_automations.add(state.entity_id)
if exclude_disabled:
_LOGGER.debug(f"Found {num_off_auto} automations in 'off' state and {num_disabled_auto} registry-disabled automations.")
_LOGGER.debug("They will be excluded from report due to user settings.")
# Normalize ignored states (e.g. unavail -> unavailable if needed, or handle in loop)
# For now, we pass raw config list and handle mapping in the loop for backward compatibility
ignored_states_mapped = set()
for s in ignored_states:
if s == "unavailable":
ignored_states_mapped.add("unavail")
else:
ignored_states_mapped.add(s)
self._filter_context_cache = FilterContext(
entity_registry=ent_reg,
disabled_automations=disabled_automations,
automation_map=automation_map,
ignored_states=ignored_states_mapped,
ignored_labels=ignored_labels,
exclude_disabled=exclude_disabled,
)
return self._filter_context_cache
@callback
def _handle_automation_state_change(self, event: Event) -> None:
"""Handle state changes for automations (toggles)."""
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
# Filter noise (attribute changes)
if old_state and new_state and old_state.state != new_state.state:
_LOGGER.debug(f"Automation state changed: {obfuscate_id(event.data['entity_id'])}")
self.invalidate_filter_context()
self.hass.async_create_task(self.async_request_refresh())
@callback
def _update_automation_listener(self) -> None:
"""Update subscription to automation state changes."""
if self._unsub_automation_listener:
self._unsub_automation_listener()
self._unsub_automation_listener = None
automation_ids = self.hass.states.async_entity_ids("automation")
if automation_ids:
_LOGGER.debug(f"Subscribing to state changes for {len(automation_ids)} automations")
self._unsub_automation_listener = async_track_state_change_event(
self.hass, automation_ids, self._handle_automation_state_change
)
else:
_LOGGER.debug("No automations found to subscribe to.")
async def async_load_stats(self) -> None:
"""Load stats from storage."""
if stats := await self._store.async_load():
self._last_parse_time = stats.get("last_parse_time_monotonic", 0.0)
self.data[COORD_DATA_PARSE_DURATION] = stats.get("duration", 0.0)
self.data[COORD_DATA_PROCESSED_FILES] = stats.get("processed_files_count", 0)
self.data[COORD_DATA_IGNORED_FILES] = stats.get("ignored_files_count", 0)
if timestamp := stats.get("timestamp"):
with contextlib.suppress(Exception):
last_parse_dt = dt_util.parse_datetime(timestamp)
if last_parse_dt and last_parse_dt.tzinfo is None:
last_parse_dt = last_parse_dt.replace(
tzinfo=dt_util.DEFAULT_TIME_ZONE
)
self.data[COORD_DATA_LAST_PARSE] = last_parse_dt
async def async_save_stats(self, parse_result: ParseResult) -> None:
"""Save stats to storage and update in-memory data."""
# Update in-memory data immediately so sensors are fresh
self.data[COORD_DATA_PARSE_DURATION] = parse_result.duration
self.data[COORD_DATA_PROCESSED_FILES] = parse_result.processed_files_count
self.data[COORD_DATA_IGNORED_FILES] = parse_result.ignored_files_count
if parse_result.timestamp:
with contextlib.suppress(Exception):
last_parse_dt = dt_util.parse_datetime(parse_result.timestamp)
if last_parse_dt and last_parse_dt.tzinfo is None:
last_parse_dt = last_parse_dt.replace(
tzinfo=dt_util.DEFAULT_TIME_ZONE
)
self.data[COORD_DATA_LAST_PARSE] = last_parse_dt
stats = {
"duration": parse_result.duration,
"timestamp": parse_result.timestamp,
"ignored_files_count": parse_result.ignored_files_count,
"processed_files_count": parse_result.processed_files_count,
"last_parse_time_monotonic": self._last_parse_time,
}
await self._store.async_save(stats)
@property
def version(self) -> str:
"""Return version of the integration from manifest file."""
return self._version
@property
def status(self) -> str:
"""Return the current status of the integration."""
return self._status
@property
def safe_mode(self) -> bool:
"""Return True if integration is in safe mode."""
return self._status == STATE_SAFE_MODE
def update_status(self, new_status: str) -> None:
"""Update the status and notify listeners."""
self._status = new_status
self.async_update_listeners()
def _update_checked_states(self) -> None:
"""Update the set of states that trigger a refresh."""
ignored_states = get_config(self.hass, CONF_IGNORED_STATES, [])
self.checked_states = set(MONITORED_STATES) - set(ignored_states)
_LOGGER.debug(f"Checked states updated: {self.checked_states}")
async def async_get_parsed_entities(self) -> dict[str, Any]:
"""Return a dictionary of parsed entities and their locations."""
return (await self.hub.async_get_all_items())["entities"]
async def async_get_parsed_services(self) -> dict[str, Any]:
"""Return a dictionary of parsed services and their locations."""
return (await self.hub.async_get_all_items())["services"]
async def async_process_parsed_data(
self, parsed_entity_list: dict[str, Any], parsed_service_list: dict[str, Any]
) -> dict[str, Any]:
"""Process parsed data to calculate missing items and build sensor attributes.
This is separated to allow 'priming' the coordinator from cache without a full scan.
"""
# Build optimized Home Assistant data context once
ctx = self._build_filter_context()
services_missing = renew_missing_items_list(
self.hass,
parsed_service_list,
ctx,
item_type="action",
)
entities_missing = renew_missing_items_list(
self.hass,
parsed_entity_list,
ctx,
item_type="entity",
)
# Initialize internal cache
self._missing_entities_cache = entities_missing
self._missing_actions_cache = services_missing
self._force_full_rescan = False
self._dirty_entities.clear()
# build entity attributes map for missing_entities sensor
entity_attrs = []
for entity in entities_missing:
reg_entry = ctx.entity_registry.async_get(entity)
state, name = get_entity_state(
self.hass, entity, friendly_names=True, registry_entry=reg_entry
)
entity_attrs.append(
{
"id": entity,
"state": state,
"friendly_name": name or "",
"occurrences": fill(parsed_entity_list[entity]["locations"], 0),
}
)
# build service attributes map for missing_services sensor
service_attrs = [
{
"id": service,
"occurrences": fill(parsed_service_list[service]["locations"], 0),
}
for service in services_missing
]
return {
COORD_DATA_MISSING_ENTITIES: len(entities_missing),
COORD_DATA_MISSING_ACTIONS: len(services_missing),
COORD_DATA_LAST_UPDATE: dt_util.now(),
COORD_DATA_SERVICE_ATTRS: service_attrs,
COORD_DATA_ENTITY_ATTRS: entity_attrs,
COORD_DATA_PARSE_DURATION: self.data.get(COORD_DATA_PARSE_DURATION, 0.0),
COORD_DATA_LAST_PARSE: self.data.get(COORD_DATA_LAST_PARSE),
COORD_DATA_PROCESSED_FILES: self.data.get(COORD_DATA_PROCESSED_FILES, 0),
COORD_DATA_IGNORED_FILES: self.data.get(COORD_DATA_IGNORED_FILES, 0),
}
async def async_get_detailed_report_data(self) -> dict[str, Any]:
"""Return detailed report data with missing items lists."""
all_items = await self.hub.async_get_all_items()
parsed_services = all_items["services"]
parsed_entities = all_items["entities"]
ctx = self._build_filter_context()
missing_services = renew_missing_items_list(
self.hass,
parsed_services,
ctx,
item_type="action",
)
missing_entities = renew_missing_items_list(
self.hass,
parsed_entities,
ctx,
item_type="entity",
)
def flatten_occurrences(
item_id: str, occurrences: list[dict[str, Any]], state: str
) -> list[dict[str, Any]]:
return [
{
"id": item_id,
"state": state,
"file": occ["path"],
"line": occ["line"],
"context": occ.get("context"),
}
for occ in occurrences
]
entities_list = []
for entity_id, occurrences in missing_entities.items():
reg_entry = ctx.entity_registry.async_get(entity_id)
state, _ = get_entity_state(self.hass, entity_id, registry_entry=reg_entry)
entities_list.extend(flatten_occurrences(entity_id, occurrences, state))
actions_list = []
for service_id, occurrences in missing_services.items():
actions_list.extend(flatten_occurrences(service_id, occurrences, "missing"))
info = {}
info["last_parse_date"] = self.data.get(COORD_DATA_LAST_PARSE)
info["parse_duration"] = self.data.get(COORD_DATA_PARSE_DURATION)
info["ignored_files_count"] = self.data.get(COORD_DATA_IGNORED_FILES)
info["processed_files_count"] = self.data.get(COORD_DATA_PROCESSED_FILES)
info["missing_entities"] = entities_list
info["missing_actions"] = actions_list
return info
def request_parser_rescan(
self,
*,
reason: str | None = None,
force: bool = False,
delay: float = DEFAULT_DELAY,
) -> None:
"""Request a background scan.
If force=True, ignore cooldown and delay.
"""
self._needs_parse = True
_LOGGER.debug(f"Parser rescan requested. Reason: {reason}, Force: {force}, Delay: {delay}")
if self.hub.is_scanning or (self._parse_task and not self._parse_task.done()):
_LOGGER.debug("Scan in progress, request queued.")
return
if force:
# if forcing, cancel any pending cooldown and delay and execute immediately
if self._cooldown_unsub:
self._cooldown_unsub.cancel()
self._cooldown_unsub = None
if self._delay_unsub:
self._delay_unsub.cancel()
self._delay_unsub = None
self._current_delay = 0
self._schedule_parse(force_immediate=True)
return
# if delayed parse already scheduled
if self._delay_unsub:
self._delay_unsub.cancel()
self._delay_unsub = None
_LOGGER.debug(f"⏳ Debouncing: previously scheduled parsing will be postponed for another {max(self._current_delay, delay)} sec")
if self._cooldown_unsub:
_LOGGER.debug("⏳ Debouncing: parser in cooldown, will be scheduled in 60 sec.")
# Smart Debounce: use the maximum of current pending delay or new delay
self._current_delay = max(self._current_delay, delay)
self.update_status(STATE_PENDING)
self._delay_unsub = self.hass.loop.call_later(self._current_delay, self._on_timer_finished, "delay")
@callback
def _on_timer_finished(self, timer_type: str) -> None:
"""Callback when a scheduled timer (delay or cooldown) finishes."""
if timer_type == "delay":
self._delay_unsub = None
self._current_delay = 0
elif timer_type == "cooldown":
self._cooldown_unsub = None
self._schedule_parse()
def _schedule_parse(self, *, force_immediate: bool = False) -> None:
"""Schedule the parse task based on state and cooldown."""
if self.hub.is_scanning or (self._parse_task and not self._parse_task.done()):
# do nothing as parsing is already running
# _needs_parse=True will trigger next parsing request with cooldown
# after current parsing is finished
_LOGGER.debug("⏳ Scheduling parse: Scan in progress, parsing request queued.")
return
# 2. Check cooldown
now = time.time()
time_since_last = now - self._last_parse_time
if not force_immediate and time_since_last < PARSE_COOLDOWN:
remaining = PARSE_COOLDOWN - time_since_last
if self._cooldown_unsub:
# Timer already running
_LOGGER.debug(f"⏳ Scheduling parse: parser in cooldown and will run again in {remaining:.1f}s")
return
_LOGGER.debug(f"⏳ Scheduling parse: parser in cooldown. Scheduling in {remaining:.1f}s")
self.update_status(STATE_PENDING)
self._cooldown_unsub = self.hass.loop.call_later(
remaining, self._on_timer_finished, "cooldown"
)
return
# 3. Start background task
_LOGGER.debug(f"🚀 Background parse started: force_immediate={force_immediate}")
self._cooldown_unsub = None
self._parse_task = self.hass.async_create_background_task(
self._execute_parse(), "watchman_parse"
)
async def async_force_parse(self) -> Any:
"""Execute a blocking parse for the report service.
Returns a Task/Coroutine that finishes when parsing is complete.
"""
# Cancel pending cooldown
if self._cooldown_unsub:
self._cooldown_unsub.cancel()
self._cooldown_unsub = None
# Cancel pending delay
if self._delay_unsub:
self._delay_unsub.cancel()
self._delay_unsub = None
# If already running, return the running task
if self._parse_task and not self._parse_task.done():
_LOGGER.debug("Force parse requested, but parser is already running. Waiting to reuse its results.")
return await self._parse_task
# Otherwise, run immediately
_LOGGER.debug("Force parse requested. Starting immediately.")
return await self._execute_parse()
async def _execute_parse(self) -> None:
"""Execute the heavy parsing logic."""
if self.safe_mode:
_LOGGER.warning("_execute_parse: Watchman is in Safe Mode. Skipping parse.")
return
self._needs_parse = False
self.update_status(STATE_PARSING)
# Create lock file
lock_path = self.hass.config.path(".storage", LOCK_FILENAME)
await self.hass.async_add_executor_job(
lambda: Path(lock_path).write_text("1", encoding="utf-8")
)
try:
ignored_files = get_config(self.hass, CONF_IGNORED_FILES, [])
# Perform the scan
if parse_result := await self.hub.async_parse(ignored_files):
self._last_parse_time = time.time()
await self.async_save_stats(parse_result)
# After scan, we definitely need full rescan of items status
self._force_full_rescan = True
# Refresh data and notify sensors
await self.async_refresh()
self.async_update_entity_tracking()
except Exception as err:
_LOGGER.exception(f"Error during watchman parse: {err}")
finally:
# Cleanup
await self.hass.async_add_executor_job(
lambda: Path(lock_path).unlink(missing_ok=True)
)
self.update_status(STATE_IDLE)
self._parse_task = None
_LOGGER.debug("🏁 Background parse finished.")
# Check if another parse was requested during execution
if self._needs_parse:
_LOGGER.debug(f"⏳ Another request occured during parser execution, will be repeated after cooldown ({PARSE_COOLDOWN} sec)")
self._schedule_parse()
async def async_get_last_parse_duration(self) -> float:
"""Return duration of the last parsing."""
return self.data.get(COORD_DATA_PARSE_DURATION, 0.0)
@callback
def _handle_state_change_event(self, event: Event) -> None:
"""Handle state change event for monitored entities."""
if self.hub.is_scanning:
_LOGGER.debug("Scan in progress, skipping state change event.")
return
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
# 1. Ignore Attribute Changes (same state value)
if old_state and new_state and old_state.state == new_state.state:
return
# 2. Availability Check
if _is_available(old_state) == _is_available(new_state):
# Status quo regarding availability (Active->Active or Missing->Missing), ignore.
return
entity_id = event.data["entity_id"]
# Track dirty entities
self._dirty_entities.add(entity_id)
if _LOGGER.isEnabledFor(logging.DEBUG):
old_s = old_state.state if old_state else "None"
new_s = new_state.state if new_state else "None"
_LOGGER.debug(f"{obfuscate_id(entity_id)} ({old_s}->{new_s}), queued for refresh. Dirty: {len(self._dirty_entities)}")
self.hass.async_create_task(self.async_request_refresh())
@callback
def async_update_entity_tracking(self) -> None:
"""Update the state change listener with the current list of monitored entities."""
self._update_checked_states()
if self._unsub_state_listener:
self._unsub_state_listener()
self._unsub_state_listener = None
if self.hub._monitored_entities:
_LOGGER.debug("Updating monitored entities listener with %s entities", len(self.hub._monitored_entities))
self._unsub_state_listener = async_track_state_change_event(
self.hass, list(self.hub._monitored_entities), self._handle_state_change_event
)
else:
_LOGGER.debug("No entities to monitor.")
def subscribe_to_events(self, entry: ConfigEntry) -> None:
"""Subscribe to Home Assistant events."""
async def async_on_configuration_changed(event: Event) -> None:
event_type = event.event_type
if event_type == EVENT_CALL_SERVICE:
service = event.data.get("service", None)
if service in WATCHED_SERVICES:
domain = event.data.get("domain", None)
self.request_parser_rescan(reason=f"{domain}.{service}")
elif event_type in WATCHED_EVENTS:
if event_type == EVENT_AUTOMATION_RELOADED:
_LOGGER.debug("Invalidating FilterContext cache due to EVENT_AUTOMATION_RELOADED")
self.invalidate_filter_context()
self._update_automation_listener()
self.request_parser_rescan(reason=str(event_type))
async def async_on_service_changed(event: Event) -> None:
if self.hub.is_scanning:
_LOGGER.debug("Scan in progress, skipping service change event.")
return
service = f"{event.data['domain']}.{event.data['service']}"
if self.hub.is_monitored_service(service):
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Monitored service changed: %s", obfuscate_id(service))
self._force_full_rescan = True
await self.async_request_refresh()
async def async_on_registry_updated(event: Event) -> None:
if event.data.get("action") in ("create", "remove", "update"):
entity_id = event.data.get("entity_id")
# 1. Automation changes -> Invalidate Context -> Full Rescan
if entity_id and entity_id.startswith("automation."):
_LOGGER.debug("Invalidating FilterContext cache due to a CRUD op. for an automation")
self.invalidate_filter_context()
self._update_automation_listener()
await self.async_request_refresh()
return
# 2. Monitored Entity changes -> Full Rescan
if entity_id and entity_id in self.hub._monitored_entities:
# Optimization Note: While we could technically use incremental update here
# (by adding to dirty_entities), we opt for a full rescan to guarantee
# consistency when metadata changes. This covers low-frequency administrative
# actions like changing labels, disabling entities, or renaming IDs.
_LOGGER.debug(f"⚡Registry update for monitored entity {obfuscate_id(entity_id)} -> Force Full Rescan")
self._force_full_rescan = True
await self.async_request_refresh()
# Config/Service/Reload events
entry.async_on_unload(
self.hass.bus.async_listen(EVENT_CALL_SERVICE, async_on_configuration_changed)
)
entry.async_on_unload(
self.hass.bus.async_listen(EVENT_AUTOMATION_RELOADED, async_on_configuration_changed)
)
entry.async_on_unload(
self.hass.bus.async_listen(EVENT_SCENE_RELOADED, async_on_configuration_changed)
)
entry.async_on_unload(
self.hass.bus.async_listen(EVENT_SERVICE_REGISTERED, async_on_service_changed)
)
entry.async_on_unload(
self.hass.bus.async_listen(EVENT_SERVICE_REMOVED, async_on_service_changed)
)
# Entity Registry Updates
entry.async_on_unload(
self.hass.bus.async_listen(EVENT_ENTITY_REGISTRY_UPDATED, async_on_registry_updated)
)
# Initial subscription to existing automations
self._update_automation_listener()
async def async_shutdown(self) -> None:
"""Cancel any scheduled tasks and listeners."""
await super().async_shutdown()
if self._cooldown_unsub:
self._cooldown_unsub.cancel()
self._cooldown_unsub = None
if self._delay_unsub:
self._delay_unsub.cancel()
self._delay_unsub = None
if self._unsub_state_listener:
self._unsub_state_listener()
self._unsub_state_listener = None
if self._unsub_automation_listener:
self._unsub_automation_listener()
self._unsub_automation_listener = None
async def _async_update_data(self) -> dict[str, Any]:
"""Update Watchman sensors.
Read from Hub/DB without triggering a parse.
"""
_LOGGER.debug("Coordinator: refresh watchman sensors requested")
if self.safe_mode:
_LOGGER.debug("Watchman in safe mode, async_update_data will return {}")
return {}
if self.hub.is_scanning:
_LOGGER.debug("Coordinator: Hub is scanning. Use cached data for sensors to avoid race conditions.")
return self.data
try:
# OPTIMIZATION: One-Pass Data Retrieval
all_items = await self.hub.async_get_all_items()
parsed_service_list = all_items["services"]
parsed_entity_list = all_items["entities"]
ctx = self._build_filter_context()
# Logic Fork: Full vs Partial
if self._force_full_rescan:
_LOGGER.debug("Coordinator: performing FULL status check.")
self._missing_entities_cache = renew_missing_items_list(
self.hass, parsed_entity_list, ctx, item_type="entity"
)
self._missing_actions_cache = renew_missing_items_list(
self.hass, parsed_service_list, ctx, item_type="action"
)
self._force_full_rescan = False
self._dirty_entities.clear()
elif self._dirty_entities:
_LOGGER.debug(f"Coordinator: performing PARTIAL status check for {len(self._dirty_entities)} entities.")
updates = self._dirty_entities.copy()
self._dirty_entities.clear()
for entity_id in updates:
if entity_id in parsed_entity_list:
# Re-check this entity
result = check_single_entity_status(
self.hass, entity_id, parsed_entity_list[entity_id], ctx, item_type="entity"
)
if result is not None:
# It is missing/invalid
self._missing_entities_cache[entity_id] = result
else:
# It is valid/available -> remove from missing cache
self._missing_entities_cache.pop(entity_id, None)
# Construct result from cache
entities_missing = self._missing_entities_cache
services_missing = self._missing_actions_cache
# build entity attributes map for missing_entities sensor
entity_attrs = []
for entity in entities_missing:
reg_entry = ctx.entity_registry.async_get(entity)
state, name = get_entity_state(
self.hass, entity, friendly_names=True, registry_entry=reg_entry
)
entity_attrs.append(
{
"id": entity,
"state": state,
"friendly_name": name or "",
"occurrences": fill(parsed_entity_list[entity]["locations"], 0),
}
)
# build service attributes map for missing_services sensor
service_attrs = [
{
"id": service,
"occurrences": fill(parsed_service_list[service]["locations"], 0),
}
for service in services_missing
]
new_data = {
COORD_DATA_MISSING_ENTITIES: len(entities_missing),
COORD_DATA_MISSING_ACTIONS: len(services_missing),
COORD_DATA_LAST_UPDATE: dt_util.now(),
COORD_DATA_SERVICE_ATTRS: service_attrs,
COORD_DATA_ENTITY_ATTRS: entity_attrs,
COORD_DATA_PARSE_DURATION: self.data.get(COORD_DATA_PARSE_DURATION, 0.0),
COORD_DATA_LAST_PARSE: self.data.get(COORD_DATA_LAST_PARSE),
COORD_DATA_PROCESSED_FILES: self.data.get(COORD_DATA_PROCESSED_FILES, 0),
COORD_DATA_IGNORED_FILES: self.data.get(COORD_DATA_IGNORED_FILES, 0),
}
self.data = new_data
_LOGGER.debug(
f"Coordinator: sensors refreshed. Actions: {new_data[COORD_DATA_MISSING_ACTIONS]}, "
f"Entities: {new_data[COORD_DATA_MISSING_ENTITIES]}"
)
return new_data
except Exception as err:
_LOGGER.exception(f"Error reading watchman data: {err}")
return self.data
+35
View File
@@ -0,0 +1,35 @@
"""Represents Watchman service in the device registry of Home Assistant."""
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import DOMAIN
class WatchmanEntity(CoordinatorEntity):
"""Representation of a Watchman entity."""
def __init__(
self,
coordinator: DataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize Watchman entity."""
super().__init__(coordinator)
self.entity_description = entity_description
# per sensor unique_id
self._attr_unique_id = f"{DOMAIN}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, "watchman_unique_id")},
manufacturer="dummylabs",
model="Watchman",
name="Watchman",
sw_version=coordinator.version,
entry_type=DeviceEntryType.SERVICE,
configuration_url="https://github.com/dummylabs/thewatchman",
)
self._attr_extra_state_attributes = {}
+159
View File
@@ -0,0 +1,159 @@
"""Watchman Hub - Asynchronous wrapper for WatchmanParser."""
from collections.abc import Callable
import fnmatch
import sqlite3
from typing import Any
from homeassistant.core import HomeAssistant
from .const import BUNDLED_IGNORED_ITEMS, CONF_IGNORED_ITEMS
from .utils.logger import _LOGGER
from .utils.parser_core import ParseResult, WatchmanParser, get_domains
from .utils.utils import get_config
class WatchmanHub:
"""Asynchronous wrapper (Adapter) for the synchronous parser."""
def __init__(self, hass: HomeAssistant, db_path: str) -> None:
self.hass = hass
self.db_path = db_path
self._is_scanning = False
# inject Home Assistant's executor to run process config files asynchronously
async def ha_executor(func: Callable, *args: Any) -> Any:
return await self.hass.async_add_executor_job(func, *args)
self._parser = WatchmanParser(db_path, executor=ha_executor)
self.cached_items = {}
self._monitored_entities = None
self._monitored_services = None
@property
def is_scanning(self) -> bool:
"""Return True if a scan is currently in progress."""
return self._is_scanning
def is_monitored_service(self, service_id: str) -> bool:
"""Check if service is monitored (fast cache check)."""
if self._monitored_services is None:
return False
return service_id in self._monitored_services
async def async_get_all_items(self) -> dict[str, dict[str, Any]]:
"""Return all parsed items (entities and services) in one pass."""
return await self.hass.async_add_executor_job(self._get_all_items_sync)
def _get_all_items_sync(self) -> dict[str, dict[str, Any]]:
"""Fetch ALL items in one go and split them in memory."""
ignored_items = get_config(self.hass, CONF_IGNORED_ITEMS, [])
final_ignored_items = list(set((ignored_items or []) + BUNDLED_IGNORED_ITEMS))
entities = {}
services = {}
try:
raw_items = self._parser.get_found_items(item_type='all')
except sqlite3.OperationalError as e:
_LOGGER.warning(f"Database busy during read, returning cached/empty data: {e}")
# Try to return cached data if available, else empty
# Note: cached_items is now structured differently?
# For simplicity in this refactor, if DB fails, return empty structure.
# Ideally we should cache the full 'entities'/'services' structure.
return {"entities": {}, "services": {}}
for item in raw_items:
entity_id, path, line, item_type = item[0], item[1], item[2], item[3]
# Filter ignored items
is_ignored = False
for pattern in final_ignored_items:
if fnmatch.fnmatch(entity_id, pattern):
is_ignored = True
break
if is_ignored:
continue
target_dict = entities if item_type == 'entity' else services
if entity_id not in target_dict:
target_dict[entity_id] = {"locations": {}, "automations": set(), "occurrences": []}
if path not in target_dict[entity_id]["locations"]:
target_dict[entity_id]["locations"][path] = []
target_dict[entity_id]["locations"][path].append(line)
parent_type = item[4]
parent_alias = item[5]
parent_id = item[6]
context = None
if parent_type or parent_alias or parent_id:
context = {
"parent_type": parent_type,
"parent_alias": parent_alias,
"parent_id": parent_id
}
target_dict[entity_id]["occurrences"].append({
"path": path,
"line": line,
"context": context
})
if parent_id and parent_type in ("automation", "script"):
target_dict[entity_id]["automations"].add(parent_id)
# Update fast lookups
self._monitored_entities = set(entities.keys())
self._monitored_services = set(services.keys())
return {"entities": entities, "services": services}
async def async_parse(
self, ignored_files: list[str], *, force: bool = False
) -> ParseResult | None:
"""Asynchronous wrapper for the parse method."""
if self._is_scanning:
_LOGGER.debug("Scan already in progress, skipping request.")
return None
self._is_scanning = True
try:
custom_domains = get_domains(self.hass)
(
_entities,
_services,
_files_parsed,
_files_ignored,
_ent_to_auto,
parse_result,
) = await self._parser.async_parse(
self.hass.config.config_dir,
ignored_files,
force=force,
custom_domains=custom_domains,
base_path=self.hass.config.config_dir,
)
self.cached_items = {}
# Reset fast cache so it's rebuilt on next access
self._monitored_entities = None
self._monitored_services = None
return parse_result
finally:
self._is_scanning = False
async def async_get_last_parse_info(self) -> dict[str, Any]:
"""Return the duration and timestamp of the last successful scan."""
return await self.hass.async_add_executor_job(self._get_last_parse_info_sync)
def _get_last_parse_info_sync(self) -> dict[str, Any]:
"""Return the duration and timestamp of the last successful scan."""
try:
return self._parser.get_last_parse_info()
except sqlite3.OperationalError as e:
_LOGGER.warning(f"Database busy (get_last_parse_info), returning default: {e}")
return {"duration": 0.0, "timestamp": None, "ignored_files_count": 0, "processed_files_count": 0}
+10
View File
@@ -0,0 +1,10 @@
{
"services": {
"report": {
"service": "mdi:shield-search",
"sections": {
"advanced_options": "mdi:shield-sun-outline"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"domain": "watchman",
"name": "Watchman",
"codeowners": ["@dummylabs"],
"config_flow": true,
"documentation": "https://github.com/dummylabs/thewatchman",
"iot_class": "local_push",
"issue_tracker": "https://github.com/dummylabs/thewatchman/issues",
"requirements": ["prettytable==3.12.0"],
"single_config_entry": true,
"version": "0.8.4"
}
+413
View File
@@ -0,0 +1,413 @@
"""Watchman sensors definition."""
from typing import Any
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.components.sensor.const import (
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MATCH_ALL, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
COORD_DATA_ENTITY_ATTRS,
COORD_DATA_IGNORED_FILES,
COORD_DATA_LAST_PARSE,
COORD_DATA_LAST_UPDATE,
COORD_DATA_MISSING_ACTIONS,
COORD_DATA_MISSING_ENTITIES,
COORD_DATA_PARSE_DURATION,
COORD_DATA_PROCESSED_FILES,
COORD_DATA_SERVICE_ATTRS,
DOMAIN,
SENSOR_IGNORED_FILES,
SENSOR_LAST_PARSE,
SENSOR_LAST_UPDATE,
SENSOR_MISSING_ACTIONS,
SENSOR_MISSING_ENTITIES,
SENSOR_PARSE_DURATION,
SENSOR_PROCESSED_FILES,
SENSOR_STATUS,
STATE_IDLE,
STATE_PARSING,
STATE_PENDING,
STATE_SAFE_MODE,
STATE_WAITING_HA,
)
from .entity import WatchmanEntity
from .utils.logger import _LOGGER
SENSORS_CONFIGURATION = [
SensorEntityDescription(
key=SENSOR_LAST_UPDATE,
translation_key="last_updated",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:calendar-clock",
),
SensorEntityDescription(
key=SENSOR_MISSING_ENTITIES,
translation_key="missing_entities",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:shield-half-full",
native_unit_of_measurement="items",
),
SensorEntityDescription(
key=SENSOR_MISSING_ACTIONS,
translation_key="missing_actions",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:shield-half-full",
native_unit_of_measurement="items",
),
SensorEntityDescription(
key=SENSOR_STATUS,
translation_key="status",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=[STATE_WAITING_HA, STATE_PARSING, STATE_PENDING, STATE_IDLE, STATE_SAFE_MODE],
),
SensorEntityDescription(
key=SENSOR_PARSE_DURATION,
translation_key="parse_duration",
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="s",
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:timer-outline",
),
SensorEntityDescription(
key=SENSOR_LAST_PARSE,
translation_key="last_parse",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:calendar-clock",
),
SensorEntityDescription(
key=SENSOR_PROCESSED_FILES,
translation_key="processed_files",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:file-document-check",
),
SensorEntityDescription(
key=SENSOR_IGNORED_FILES,
translation_key="ignored_files",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:file-remove",
),
]
async def update_or_cleanup_entity(
ent_reg: er.EntityRegistry, old_uid: str, new_uid: str
) -> None:
if old_entity_id := ent_reg.async_get_entity_id("sensor", DOMAIN, old_uid):
# we found entities with old-style uid in registry, apply migration logic
if ent_reg.async_get_entity_id("sensor", DOMAIN, new_uid):
ent_reg.async_remove(old_entity_id)
_LOGGER.debug(f"async_setup_entry: 2 entities found in registry. Will remove {old_uid} in favor of {new_uid}.")
else:
_LOGGER.debug(f"async_setup_entry: Entity with old uid {old_uid} was migrated to {new_uid}.")
ent_reg.async_update_entity(old_entity_id, new_unique_id=new_uid)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_devices: AddEntitiesCallback
) -> None:
"""Set up sensor platform."""
_LOGGER.debug("async_setup_entry called")
coordinator = hass.data[DOMAIN][entry.entry_id]
ent_reg = er.async_get(hass)
entities = []
for description in SENSORS_CONFIGURATION:
# migration logic
# fixing the bug in WM prior to 8.x where sensor uids were generated using entry uid
# which led to duplication of entities after integration reinstall
# e.g. 0A3F1123_watchman_status -> watchman_status
old_uid = f"{entry.entry_id}_{DOMAIN}_{description.key}"
new_uid = f"{DOMAIN}_{description.key}"
await update_or_cleanup_entity(ent_reg, old_uid, new_uid)
# fix for duplicated domain uid, introduced by first dev versions of 0.8
# e.g. watchman_watchman_status -> watchman_status
# FIXME: for development versions only, remove this code after 0.8.3 is released
dub_uid = f"{DOMAIN}_{DOMAIN}_{description.key}"
await update_or_cleanup_entity(ent_reg, dub_uid, new_uid)
# Instantiate sensor classes
if description.key == SENSOR_LAST_UPDATE:
entities.append(LastUpdateSensor(coordinator, description))
elif description.key == SENSOR_MISSING_ENTITIES:
entities.append(MissingEntitiesSensor(coordinator, description))
elif description.key == SENSOR_MISSING_ACTIONS:
entities.append(MissingActionsSensor(coordinator, description))
elif description.key == SENSOR_STATUS:
entities.append(StatusSensor(coordinator, description))
elif description.key == SENSOR_PARSE_DURATION:
entities.append(ParseDurationSensor(coordinator, description))
elif description.key == SENSOR_LAST_PARSE:
entities.append(LastParseSensor(coordinator, description))
elif description.key == SENSOR_PROCESSED_FILES:
entities.append(ProcessedFilesSensor(coordinator, description))
elif description.key == SENSOR_IGNORED_FILES:
entities.append(IgnoredFilesSensor(coordinator, description))
async_add_devices(entities)
class LastUpdateSensor(WatchmanEntity, SensorEntity):
"""Timestamp sensor for last watchman update time."""
_attr_should_poll = False
_attr_has_entity_name = True
@property
def should_poll(self) -> bool:
"""No polling needed."""
return False
@property
def native_value(self) -> Any:
"""Return the native value of the sensor."""
if self.coordinator.data:
return self.coordinator.data.get(COORD_DATA_LAST_UPDATE)
return None
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.data:
self._attr_native_value = self.coordinator.data.get(COORD_DATA_LAST_UPDATE)
self.async_write_ha_state()
super()._handle_coordinator_update()
class MissingEntitiesSensor(WatchmanEntity, SensorEntity):
"""Number of missing entities from watchman report."""
_attr_should_poll = False
_attr_has_entity_name = True
_unrecorded_attributes = frozenset({MATCH_ALL})
@property
def should_poll(self) -> bool:
"""No polling needed."""
return False
@property
def native_value(self) -> Any:
"""Return the native value of the sensor."""
if self.coordinator.data:
return self.coordinator.data.get(COORD_DATA_MISSING_ENTITIES)
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
if self.coordinator.data:
return {"entities": self.coordinator.data.get(COORD_DATA_ENTITY_ATTRS, [])}
return {}
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.data:
self._attr_native_value = self.coordinator.data.get(COORD_DATA_MISSING_ENTITIES)
self._attr_extra_state_attributes = {
"entities": self.coordinator.data.get(COORD_DATA_ENTITY_ATTRS, [])
}
self.async_write_ha_state()
super()._handle_coordinator_update()
class MissingActionsSensor(WatchmanEntity, SensorEntity):
"""Number of missing services from watchman report."""
_attr_should_poll = False
_attr_has_entity_name = True
_unrecorded_attributes = frozenset({MATCH_ALL})
@property
def should_poll(self) -> bool:
"""No polling needed."""
return False
@property
def native_value(self) -> Any:
"""Return the native value of the sensor."""
if self.coordinator.data:
return self.coordinator.data.get(COORD_DATA_MISSING_ACTIONS)
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
if self.coordinator.data:
return {"entities": self.coordinator.data.get(COORD_DATA_SERVICE_ATTRS, [])}
return {}
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.data:
self._attr_native_value = self.coordinator.data.get(COORD_DATA_MISSING_ACTIONS)
self._attr_extra_state_attributes = {
"services": self.coordinator.data.get(COORD_DATA_SERVICE_ATTRS, [])
}
self.async_write_ha_state()
super()._handle_coordinator_update()
class StatusSensor(WatchmanEntity, SensorEntity):
"""Diagnostic sensor for Watchman status."""
_attr_should_poll = False
_attr_has_entity_name = True
@property
def should_poll(self) -> bool:
"""No polling needed."""
return False
@property
def native_value(self) -> str | None:
"""Return the native value of the sensor."""
return self.coordinator.status
@property
def icon(self) -> str:
"""Return dynamic icon based on status."""
if self.coordinator.status == STATE_PARSING:
return "mdi:progress-clock"
if self.coordinator.status == STATE_PENDING:
return "mdi:timer-sand"
if self.coordinator.status == STATE_IDLE:
return "mdi:sleep"
if self.coordinator.status == STATE_SAFE_MODE:
return "mdi:shield-alert"
return "mdi:timer-sand"
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_native_value = self.coordinator.status
self.async_write_ha_state()
super()._handle_coordinator_update()
class ParseDurationSensor(WatchmanEntity, SensorEntity):
"""Sensor for last parse duration."""
_attr_should_poll = False
_attr_has_entity_name = True
@property
def should_poll(self) -> bool:
"""No polling needed."""
return False
@property
def native_value(self) -> Any:
"""Return the native value of the sensor."""
if self.coordinator.data:
return self.coordinator.data.get(COORD_DATA_PARSE_DURATION)
return None
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.data:
self._attr_native_value = self.coordinator.data.get(COORD_DATA_PARSE_DURATION)
self.async_write_ha_state()
super()._handle_coordinator_update()
class LastParseSensor(WatchmanEntity, SensorEntity):
"""Timestamp sensor for last parse time."""
_attr_should_poll = False
_attr_has_entity_name = True
@property
def should_poll(self) -> bool:
"""No polling needed."""
return False
@property
def native_value(self) -> Any:
"""Return the native value of the sensor."""
if self.coordinator.data:
return self.coordinator.data.get(COORD_DATA_LAST_PARSE)
return None
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.data:
self._attr_native_value = self.coordinator.data.get(COORD_DATA_LAST_PARSE)
self.async_write_ha_state()
super()._handle_coordinator_update()
class ProcessedFilesSensor(WatchmanEntity, SensorEntity):
"""Sensor for number of processed files."""
_attr_should_poll = False
_attr_has_entity_name = True
@property
def should_poll(self) -> bool:
"""No polling needed."""
return False
@property
def native_value(self) -> Any:
"""Return the native value of the sensor."""
if self.coordinator.data:
return self.coordinator.data.get(COORD_DATA_PROCESSED_FILES)
return None
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.data:
self._attr_native_value = self.coordinator.data.get(COORD_DATA_PROCESSED_FILES)
self.async_write_ha_state()
super()._handle_coordinator_update()
class IgnoredFilesSensor(WatchmanEntity, SensorEntity):
"""Sensor for number of ignored files."""
_attr_should_poll = False
_attr_has_entity_name = True
@property
def should_poll(self) -> bool:
"""No polling needed."""
return False
@property
def native_value(self) -> Any:
"""Return the native value of the sensor."""
if self.coordinator.data:
return self.coordinator.data.get(COORD_DATA_IGNORED_FILES)
return None
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.coordinator.data:
self._attr_native_value = self.coordinator.data.get(COORD_DATA_IGNORED_FILES)
self.async_write_ha_state()
super()._handle_coordinator_update()
+118
View File
@@ -0,0 +1,118 @@
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import label_registry as lr
from .const import (
CONF_ACTION_NAME,
CONF_ALLOWED_SERVICE_PARAMS,
CONF_CHUNK_SIZE,
CONF_CREATE_FILE,
CONF_IGNORED_LABELS,
CONF_PARSE_CONFIG,
CONF_REPORT_PATH,
CONF_SEND_NOTIFICATION,
CONF_SERVICE_DATA,
CONF_SERVICE_NAME,
DOMAIN,
LABELS_SERVICE_NAME,
REPORT_SERVICE_NAME,
)
from .utils.logger import _LOGGER
from .utils.report import async_report_to_file, async_report_to_notification
from .utils.utils import get_config
class WatchmanServicesSetup:
"""Class to handle Integration Services."""
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialise services."""
self.hass = hass
self.config_entry = config_entry
self.coordinator = config_entry.runtime_data.coordinator
self.setup_services()
def setup_services(self) -> None:
"""Initialise the services in Hass."""
self.hass.services.async_register(
DOMAIN,
REPORT_SERVICE_NAME,
self.async_handle_report,
supports_response=SupportsResponse.OPTIONAL
)
self.hass.services.async_register(
DOMAIN,
LABELS_SERVICE_NAME,
self.async_handle_set_ignored_labels,
)
async def async_handle_set_ignored_labels(self, call: ServiceCall) -> None:
"""Set ignored labels."""
labels = call.data.get("labels", [])
registry = lr.async_get(self.hass)
existing_labels = {l.label_id for l in registry.async_list_labels()}
invalid_labels = [l for l in labels if l not in existing_labels]
if invalid_labels:
raise ServiceValidationError(
f"The following labels do not exist: {', '.join(invalid_labels)}"
)
self.hass.config_entries.async_update_entry(
self.config_entry,
data={**self.config_entry.data, CONF_IGNORED_LABELS: labels}
)
async def async_handle_report(self, call: ServiceCall) -> dict[str, Any]:
"""Handle the action call."""
path = get_config(self.hass, CONF_REPORT_PATH)
send_notification = call.data.get(CONF_SEND_NOTIFICATION, False)
create_file = call.data.get(CONF_CREATE_FILE, True)
action_data = call.data.get(CONF_SERVICE_DATA, None)
chunk_size = call.data.get(CONF_CHUNK_SIZE, 0)
# validate action params
for param in call.data:
if param not in CONF_ALLOWED_SERVICE_PARAMS:
raise ServiceValidationError(f"Unknown action parameter: `{param}`.")
action_name = call.data.get(
CONF_ACTION_NAME, call.data.get(CONF_SERVICE_NAME, None)
)
if action_data and not action_name:
raise ServiceValidationError(
f"Missing [{CONF_ACTION_NAME}] parameter. The [{CONF_SERVICE_DATA}] parameter can only be used "
f"in conjunction with [{CONF_ACTION_NAME}] parameter."
)
_LOGGER.debug(f"User requested report params={call.data}")
if call.data.get(CONF_PARSE_CONFIG, False):
# Blocking wait for a fresh scan
await self.coordinator.async_force_parse()
else:
# FIX: Ensure sensors perform a FULL check to match the generated report,
# ignoring the incremental optimization.
self.coordinator._force_full_rescan = True
await self.coordinator.async_request_refresh()
# call notification action even when send notification = False
if send_notification or action_name:
await async_report_to_notification(
self.hass, action_name, action_data, chunk_size
)
if create_file:
try:
await async_report_to_file(self.hass, path)
except OSError as exception:
raise ServiceValidationError(
f"Unable to write report to file '{exception.filename}': {exception.strerror} [Error:{exception.errno}]"
)
return await self.coordinator.async_get_detailed_report_data()
+50
View File
@@ -0,0 +1,50 @@
report:
name: Report
description: Run watchman report
fields:
parse_config:
example: true
default: false
required: false
selector:
boolean:
advanced_options:
collapsed: true
fields:
action:
example: "persistent_notification.create"
required: false
advanced: true
selector:
text:
data:
example: "title: Watchman Report"
required: false
advanced: true
chunk_size:
example: 3500
required: false
advanced: true
selector:
number:
min: 0
max: 100000
mode: box
create_file:
example: true
default: true
required: false
selector:
boolean:
set_ignored_labels:
name: Set ignored labels
description: Overwrite the list of labels to ignore.
fields:
labels:
name: Labels
description: List of labels to ignore.
required: true
selector:
label:
multiple: true
+120
View File
@@ -0,0 +1,120 @@
"""Text entity for Watchman ignored labels."""
from homeassistant.components.text import TextEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir, label_registry as lr
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import CONF_IGNORED_LABELS, DOMAIN
from .coordinator import WatchmanCoordinator
class WatchmanIgnoredLabelsText(RestoreEntity, TextEntity):
"""Text entity to manage ignored labels."""
_attr_has_entity_name = True
_attr_translation_key = "ignored_labels"
_attr_icon = "mdi:label-off"
def __init__(self, hass: HomeAssistant, coordinator: WatchmanCoordinator) -> None:
"""Initialize the entity."""
self.hass = hass
self.coordinator = coordinator
self._attr_unique_id = f"{DOMAIN}_ignored_labels"
self.entity_id = f"text.{DOMAIN}_ignored_labels"
self._attr_native_value = ""
# Orphan entity: No device_info, so it won't appear on the device page
@property
def native_value(self) -> str:
"""Return the value of the text entity."""
labels = self.coordinator.config_entry.data.get(CONF_IGNORED_LABELS, [])
return ", ".join(labels)
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
# Migration: Restore state to config entry if key is missing
if CONF_IGNORED_LABELS not in self.coordinator.config_entry.data:
if (state := await self.async_get_last_state()) is not None:
restored_labels = self._parse_labels(state.state)
if restored_labels:
self.hass.config_entries.async_update_entry(
self.coordinator.config_entry,
data={**self.coordinator.config_entry.data, CONF_IGNORED_LABELS: restored_labels}
)
else:
# If no restored state, initialize key to empty list to mark migration done
self.hass.config_entries.async_update_entry(
self.coordinator.config_entry,
data={**self.coordinator.config_entry.data, CONF_IGNORED_LABELS: []}
)
async def async_set_value(self, value: str) -> None:
"""Set the text value."""
# Create deprecation issue
ir.async_create_issue(
self.hass,
DOMAIN,
"deprecated_ignored_labels_entity",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_text_entity",
translation_placeholders={
"entity_id": self.entity_id,
},
)
valid_labels, invalid_labels = self._validate_labels(value)
if invalid_labels:
await self.hass.services.async_call(
"persistent_notification",
"create",
{
"title": "Watchman: Invalid Labels",
"message": f"The following labels were not found and ignored: {', '.join(invalid_labels)}",
"notification_id": "watchman_invalid_labels",
},
)
# Save to config entry (triggers reload)
self.hass.config_entries.async_update_entry(
self.coordinator.config_entry,
data={**self.coordinator.config_entry.data, CONF_IGNORED_LABELS: valid_labels}
)
def _parse_labels(self, value: str) -> list[str]:
"""Parse comma-separated string to list."""
if not value:
return []
return [x.strip() for x in value.split(",") if x.strip()]
def _validate_labels(self, value: str) -> tuple[list[str], list[str]]:
"""Validate labels against registry."""
registry = lr.async_get(self.hass)
existing_labels = {l.label_id for l in registry.async_list_labels()}
input_labels = self._parse_labels(value)
valid = []
invalid = []
for label in input_labels:
if label in existing_labels:
valid.append(label)
else:
invalid.append(label)
return valid, invalid
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the text platform."""
coordinator = config_entry.runtime_data.coordinator
async_add_entities([WatchmanIgnoredLabelsText(hass, coordinator)])
@@ -0,0 +1,146 @@
{
"config": {
"abort": {
"single_instance_allowed": "Only one instance of watchman is allowed"
},
"step": {}
},
"options": {
"error": {
"invalid_included_folders": "included_folders should be a comma separated list of configuration folders",
"invalid_columns_width": "Report column width should be a list of 3 positive integers",
"malformed_json": "Notification action data should be a valid json dictionary",
"unknown_service": "unknown action: `{service}`",
"invalid_report_path": "report file location is invalid, the path does not exist"
},
"step": {
"init": {
"title": "Watchman settings",
"data": {
"included_folders": "Folders to watch:",
"ignored_items": "Ignored entities and actions:",
"ignored_states": "Exclude entities with the states below from the report:",
"exclude_disabled_automation": "Exclude disabled automations",
"ignored_files": "Ignored files:",
"ignored_labels": "Ignored labels:",
"check_lovelace": "Parse UI controlled dashboards",
"startup_delay": "Startup delay for watchman sensors initialization",
"log_obfuscate": "Obfuscate sensitive data in logs"
},
"data_description": {
"included_folders": "Comma-separated list of folders where watchman should look for config files",
"ignored_items": "Comma-separated list of entities and actions excluded from tracking",
"ignored_states": "Comma-separated list of the states excluded from tracking",
"ignored_files": "Comma-separated list of config files excluded from tracking",
"ignored_labels": "Use labels to exclude entities from tracking",
"log_obfuscate": "Whether to mask entity and action names in debug logs for privacy",
"exclude_disabled_automation": "Exclude entities used only by disabled automations"
},
"sections": {
"appearance_location_options": {
"name": "Report appearance and location",
"data": {
"columns_width": "List of report columns width, e.g. 30, 7, 60",
"report_header": "Custom header for the report",
"report_path": "Report file location e.g. /config/report.txt",
"friendly_names": "Add entity friendly names to the report"
}
}
},
"description": "[Help on settings]({url})"
}
}
},
"services": {
"report": {
"name": "Report",
"description": "Run the Watchman report",
"fields": {
"create_file": {
"name": "Create file report",
"description": "Whether report file should be created (Usually True)"
},
"action": {
"name": "Send report as notification",
"description": "Optional notification action to send report via (e.g. `persistent_notification.create`)"
},
"data": {
"name": "Notification action data parameters",
"description": "Optional parameters for notification action (e.g. `title: Report`)"
},
"parse_config": {
"name": "Force configuration parsing",
"description": "Parse configuration files before generating the report. Usually, this is done automatically by Watchman, so this flag is typically not required."
},
"chunk_size": {
"name": "Notification message chunk size",
"description": "Maximum message size in bytes. Some notification services limit the maximum message size. If the report size exceeds chunk_size, it will be sent in multiple subsequent notifications.(optional)"
}
},
"sections": {
"advanced_options": {
"name": "Advanced options"
}
}
},
"set_ignored_labels": {
"name": "Set ignored labels",
"description": "Overwrite the list of labels to ignore.",
"fields": {
"labels": {
"name": "Labels",
"description": "List of labels to ignore."
}
}
}
},
"issues": {
"deprecated_text_entity": {
"title": "Entity {entity_id} is deprecated",
"description": "The use of `{entity_id}` is deprecated and will be removed in a future version of Watchman. Please reconfigure your automations to use the `watchman.set_ignored_labels` action."
}
},
"entity": {
"sensor": {
"last_updated": {
"name": "Last Update"
},
"missing_entities": {
"name": "Missing Entities"
},
"missing_actions": {
"name": "Missing Actions"
},
"status": {
"name": "Status",
"state": {
"waiting_for_ha": "Waiting for HA Start",
"parsing": "Parsing Configuration",
"idle": "Idle"
}
},
"parse_duration": {
"name": "Parse Duration"
},
"last_parse": {
"name": "Last Parse"
},
"processed_files": {
"name": "Processed Files"
},
"ignored_files": {
"name": "Ignored Files"
}
},
"button": {
"create_report_file": {
"name": "Create Report File"
}
},
"text": {
"ignored_labels": {
"name": "Ignored Labels"
}
}
}
}
@@ -0,0 +1,103 @@
{
"config": {
"abort": {
"single_instance_allowed": "Une seule instance de Watchman est autorisée"
},
"step": {}
},
"options": {
"error": {
"invalid_included_folders": "included_folders doit être une liste de dossiers de configuration séparés par des virgules",
"invalid_columns_width": "La largeur de la colonne du rapport doit être une liste de 3 entiers positifs",
"malformed_json": "Les données d'action de notification doivent être un dictionnaire JSON valide",
"unknown_service": "Action inconnue : `{service}`",
"invalid_report_path": "L'emplacement du fichier de rapport n'est pas valide, le chemin n'existe pas"
},
"step": {
"init": {
"title": "Paramètres de Watchman",
"data": {
"included_folders": "Dossiers à surveiller :",
"ignored_items": "Entités et actions ignorées :",
"ignored_states": "Exclure du rapport les entités avec les états ci-dessous :",
"exclude_disabled_automation": "Exclure les automatisations désactivées",
"ignored_files": "Fichiers ignorés:",
"ignored_labels": "Étiquettes ignorées :",
"check_lovelace": "Analyser les tableaux de bord contrôlés par l'interface utilisateur",
"startup_delay": "Délai de démarrage pour l'initialisation des capteurs Watchman",
"log_obfuscate": "Masquer les données sensibles"
},
"data_description": {
"included_folders": "Liste séparée par des virgules des dossiers dans lesquels Watchman doit rechercher les fichiers de configuration",
"ignored_items": "Liste séparée par des virgules des entités et des actions exclues du suivi",
"ignored_states": "Liste séparée par des virgules des États exclus du suivi",
"exclude_disabled_automation": "Exclure les entités utilisées uniquement par des automatisations désactivées",
"ignored_files": "Liste séparée par des virgules des fichiers de configuration exclus du suivi",
"ignored_labels": "Utiliser des étiquettes pour exclure des entités du suivi",
"log_obfuscate": "Masquer les noms d'entités et de services dans les journaux pour la confidentialité"
},
"sections": {
"appearance_location_options": {
"name": "Signaler l'apparence et l'emplacement",
"data": {
"columns_width": "Liste des largeurs des colonnes du rapport, par exemple 30, 7, 60",
"report_header": "En-tête personnalisé pour le rapport",
"report_path": "Emplacement du fichier de rapport, par exemple /config/report.txt",
"friendly_names": "Ajouter des noms conviviaux d'entité au rapport"
}
}
},
"description": "[Aide pour les paramètres on settings]({url})"
}
}
},
"services": {
"report": {
"name": "Rapport",
"description": "Exécuter le rapport Watchman",
"fields": {
"create_file": {
"name": "Créer un rapport de fichier",
"description": "Si le fichier de rapport doit être créé (généralement vrai)"
},
"action": {
"name": "Envoyer le rapport comme notification",
"description": "Action de notification facultative pour envoyer un rapport via (e.g. `persistent_notification.create`)"
},
"data": {
"name": "Paramètres des données d'action de notification",
"description": "Paramètres facultatifs pour l'action de notification (e.g. `titre : Rapport`)"
},
"parse_config": {
"name": "Force l'analyse de la configuration",
"description": "Analyser les fichiers de configuration avant de générer le rapport. Cette opération est généralement effectuée automatiquement par Watchman ; cette option n'est donc généralement pas requise."
},
"chunk_size": {
"name": "Taille du segment du message de notification",
"description": "Taille maximale des messages (en octets). Certains services de notification limitent la taille maximale des messages. Si la taille du rapport dépasse chunk_size, il sera envoyé dans plusieurs notifications ultérieures (facultatif)."
}
},
"sections": {
"advanced_options": {
"name": "Options avancées"
}
}
},
"set_ignored_labels": {
"name": "Définir les étiquettes ignorées",
"description": "Remplacer la liste des étiquettes à ignorer.",
"fields": {
"labels": {
"name": "Étiquettes",
"description": "Liste des étiquettes à ignorer."
}
}
}
},
"issues": {
"deprecated_text_entity": {
"title": "L'entité {entity_id} est obsolète",
"description": "L'utilisation de `{entity_id}` est obsolète et sera supprimée dans une future version de Watchman. Veuillez reconfigurer vos automatisations pour utiliser l'action `watchman.set_ignored_labels`."
}
}
}
@@ -0,0 +1,103 @@
{
"config": {
"abort": {
"single_instance_allowed": "Apenas é permitida uma única instância do watchman"
},
"step": {}
},
"options": {
"error": {
"invalid_included_folders": "included_folders deve ser uma lista de pastas de configuração separadas por vírgulas",
"invalid_columns_width": "A largura da coluna do relatório deve ser uma lista de 3 inteiros positivos",
"malformed_json": "Os dados da ação de notificação devem ser um dicionário JSON válido",
"unknown_service": "ação desconhecida: `{service}`",
"invalid_report_path": "O local do arquivo de relatório é inválido, o caminho não existe"
},
"step": {
"init": {
"title": "Configurações do Watchman",
"data": {
"included_folders": "Pastas para monitorar:",
"ignored_items": "Entidades e ações ignoradas:",
"ignored_states": "Excluir entidades com os estados abaixo do relatório:",
"exclude_disabled_automation": "Excluir automações desativadas",
"ignored_files": "Arquivos ignorados:",
"ignored_labels": "Etiquetas ignoradas:",
"check_lovelace": "Analisar dashboards controlados pela UI",
"startup_delay": "Atraso de inicialização para a inicialização dos sensores do watchman",
"log_obfuscate": "Ofuscar dados sensíveis nos logs"
},
"data_description": {
"included_folders": "Lista de pastas separadas por vírgulas onde o watchman deve procurar arquivos de configuração",
"ignored_items": "Lista de entidades e ações separadas por vírgulas excluídas do rastreamento",
"ignored_states": "Lista de estados separados por vírgulas excluídos do rastreamento",
"exclude_disabled_automation": "Excluir entidades usadas apenas por automações desativadas",
"ignored_files": "Lista de arquivos de configuração separados por vírgulas excluídos do rastreamento",
"ignored_labels": "Usar etiquetas para excluir entidades do rastreamento",
"log_obfuscate": "Se os nomes de entidades e serviços devem ser ocultados nos logs para privacidade"
},
"sections": {
"appearance_location_options": {
"name": "Aparência e localização do relatório",
"data": {
"columns_width": "Lista de larguras das colunas do relatório, por exemplo, 30, 7, 60",
"report_header": "Cabeçalho personalizado para o relatório",
"report_path": "Local do arquivo de relatório, por exemplo, /config/report.txt",
"friendly_names": "Adicionar nomes amigáveis das entidades ao relatório"
}
}
},
"description": "[Ajuda nas configurações]({url})"
}
}
},
"services": {
"report": {
"name": "Relatório",
"description": "Executar o relatório do Watchman",
"fields": {
"create_file": {
"name": "Criar arquivo de relatório",
"description": "Se o arquivo de relatório deve ser criado (geralmente True)"
},
"action": {
"name": "Enviar relatório como notificação",
"description": "Ação de notificação opcional para enviar o relatório (por exemplo, `persistent_notification.create`)"
},
"data": {
"name": "Parâmetros de dados da ação de notificação",
"description": "Parâmetros opcionais para a ação de notificação (por exemplo, `title: Relatório`)"
},
"parse_config": {
"name": "Forçar a análise da configuração",
"description": "Analisar os arquivos de configuração antes de gerar o relatório. Normalmente, isso é feito automaticamente pelo Watchman, então esta flag geralmente não é necessária."
},
"chunk_size": {
"name": "Tamanho do bloco da mensagem de notificação",
"description": "Tamanho máximo da mensagem em bytes. Alguns serviços de notificação limitam o tamanho máximo da mensagem. Se o tamanho do relatório exceder `chunk_size`, ele será enviado em várias notificações subsequentes. (opcional)"
}
},
"sections": {
"advanced_options": {
"name": "Opções avançadas"
}
}
},
"set_ignored_labels": {
"name": "Definir etiquetas ignoradas",
"description": "Substituir a lista de etiquetas a ignorar.",
"fields": {
"labels": {
"name": "Etiquetas",
"description": "Lista de etiquetas a ignorar."
}
}
}
},
"issues": {
"deprecated_text_entity": {
"title": "A entidade {entity_id} está obsoleta",
"description": "O uso de `{entity_id}` é obsoleto e será removido em uma versão futura do Watchman. Por favor, reconfigure suas automações para usar a ação `watchman.set_ignored_labels`."
}
}
}
@@ -0,0 +1,103 @@
{
"config": {
"abort": {
"single_instance_allowed": "Povolený je len jeden prípad watchman"
},
"step": {}
},
"options": {
"error": {
"invalid_included_folders": "included_folders by mal byť čiarkami oddelený zoznam konfiguračných priečinkov",
"invalid_columns_width": "Šírka stĺpca v prehľade by mala byť zoznamom 3 kladných celých čísel",
"malformed_json": "Údaje o notifikačnej akcii by mali byť platným slovníkom JSON",
"unknown_service": "neznáma akcia: `{service}`",
"invalid_report_path": "Umiestnenie súboru s prehľadom je neplatné, cesta neexistuje"
},
"step": {
"init": {
"title": "Watchman nastavenia",
"data": {
"included_folders": "Priečinky na sledovanie:",
"ignored_items": "Ignorované entity a akcie:",
"ignored_states": "Vylúčiť entity s nasledujúcimi stavmi z prehľadu:",
"exclude_disabled_automation": "Vylúčiť zakázané automatizácie",
"ignored_files": "Ignorované súbory:",
"ignored_labels": "Ignorované štítky:",
"check_lovelace": "Analyzovať ovládané UI ovládacie panely",
"startup_delay": "Oneskorenie spustenia pre inicializáciu senzorov watchman",
"log_obfuscate": "Zatmavenie citlivých údajov"
},
"data_description": {
"included_folders": "Čiarkami oddelený zoznam priečinkov, kde by mal watchman hľadať konfiguračné súbory",
"ignored_items": "Čiarkami oddelený zoznam entít a akcií vylúčených zo sledovania",
"ignored_states": "Čiarkami oddelený zoznam stavov vylúčených zo sledovania",
"exclude_disabled_automation": "Vylúčiť entity použité iba v deaktivovaných automatizáciách",
"ignored_files": "Čiarkami oddelený zoznam konfiguračných súborov vylúčených zo sledovania",
"ignored_labels": "Použiť štítky na vylúčenie entít zo sledovania",
"log_obfuscate": "Či sa majú v logoch z dôvodu ochrany osobných údajov maskovať názvy entít a služieb"
},
"sections": {
"appearance_location_options": {
"name": "Vzhľad a umiestnenie prehľadu",
"data": {
"columns_width": "Zoznam šírok stĺpcov prehľadu, napr. 30, 7, 60",
"report_header": "Vlastná hlavička prehľadu",
"report_path": "Umiestnenie súboru s prehľadom, napr. /config/report.txt",
"friendly_names": "Pridať priateľské mená entít do prehľadu"
}
}
},
"description": "[Pomoc s nastaveniami]({url})"
}
}
},
"services": {
"report": {
"name": "Prehľad",
"description": "Spustiť prehľad Watchman",
"fields": {
"create_file": {
"name": "Vytvoriť súbor s prehľadom",
"description": "Či by mal byť vytvorený súbor s prehľadom (zvyčajne True)"
},
"action": {
"name": "Poslať prehľad ako notifikáciu",
"description": "Voliteľná notifikačná akcia na odoslanie prehľadu (napr. `persistent_notification.create`)"
},
"data": {
"name": "Parametre údajov notifikačnej akcie",
"description": "Voliteľné parametre pre notifikačnú akciu (napr. `title: Prehľad`)"
},
"parse_config": {
"name": "Vynútená analýza konfigurácie",
"description": "Analyzovať konfiguračné súbory pred generovaním prehľadu. Zvyčajne to vykonáva automaticky Watchman, takže táto príznak nie je zvyčajne potrebná."
},
"chunk_size": {
"name": "Veľkosť časti notifikačnej správy",
"description": "Maximálna veľkosť správy v bajtoch. Niektoré notifikačné služby limitujú maximálnu veľkosť správy. Ak veľkosť prehľadu presahuje `chunk_size`, bude poslaný vo viacerých následných notifikáciách. (voliteľné)"
}
},
"sections": {
"advanced_options": {
"name": "Pokročilé možnosti"
}
}
},
"set_ignored_labels": {
"name": "Nastaviť ignorované štítky",
"description": "Prepísať zoznam štítkov, ktoré sa majú ignorovať.",
"fields": {
"labels": {
"name": "Štítky",
"description": "Zoznam štítkov na ignorovanie."
}
}
}
},
"issues": {
"deprecated_text_entity": {
"title": "Entita {entity_id} je zastaraná",
"description": "Používanie `{entity_id}` je zastarané a v budúcej verzii Watchman bude odstránené. Prekonfigurujte prosím svoje automatizácie na používanie akcie `watchman.set_ignored_labels`."
}
}
}
@@ -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)