Files
HomeAssistantVS/custom_components/watchman/coordinator.py
T
2026-07-20 22:52:35 -04:00

992 lines
38 KiB
Python

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