Files
Home-Assistant/custom_components/watchman/__init__.py
T
2026-07-20 22:52:35 -04:00

356 lines
13 KiB
Python

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