356 lines
13 KiB
Python
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)
|