Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions appdaemon/app_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def __init__(self, ad: "AppDaemon"):
self.diag = ad.logging.get_diag()
self.filter_files = {}
self.objects = {}
# Namespace -> apps that were stopped when the plugin failed.
self.stopped_plugin_apps: dict[str, set[str]] = {}

# Add Path for adbase
sys.path.insert(0, os.path.dirname(__file__))
Expand Down Expand Up @@ -496,7 +498,7 @@ async def stop_app(self, app_name: str, *, delete: bool = False) -> bool:
bool: Whether stopping was successful or not
"""
try:
if isinstance(self.app_config[app_name], AppConfig):
if isinstance(self.app_config.root.get(app_name), AppConfig):
self.logger.debug("Stopping app '%s'", app_name)
await self.terminate_app(app_name, delete=delete)
except Exception:
Expand Down Expand Up @@ -780,7 +782,7 @@ async def check_app_config_files(self, update_actions: UpdateActions):
) # fmt: skip
update_actions.apps.term |= deleted_apps
for name in deleted_apps:
# del self.app_config.root[name]
self.app_config.root.pop(name, None)
self.logger.info("App config deleted: %s", name)

self.app_config.root.update(freshly_read_cfg.root)
Expand All @@ -789,13 +791,16 @@ async def check_app_config_files(self, update_actions: UpdateActions):
# If there are any new/modified apps, the dependency graph needs to be updated
self.dependency_manager.app_deps.refresh_dep_graph()

update_actions.apps.init |= {
name for name, cfg in self.app_config.root.items()
if name not in self.objects
and isinstance(cfg, AppConfig)
and not cfg.disable
and await self.get_state(name) != "compile_error"
} # fmt: skip
if self.AD.plugins.any_plugin_inactive():
self.logger.debug("Skipping init repopulation because a plugin is inactive")
else:
update_actions.apps.init |= {
name for name, cfg in self.app_config.root.items()
if name not in self.objects
and isinstance(cfg, AppConfig)
and not cfg.disable
and await self.get_state(name) != "compile_error"
} # fmt: skip

@executor_decorator
def read_config_file(self, file: Path) -> AllAppConfig:
Expand Down Expand Up @@ -1177,20 +1182,33 @@ def get_namespace_apps(self, namespace: str) -> set[str]:
) # fmt: skip

async def _stop_plugin_apps(self, plugin_ns: str | None, update_actions: UpdateActions):
"""Stops the apps running in the namespace of a plugin that just failed, so they can be
restored by :py:meth:`~_start_plugin_apps` when the plugin reconnects."""
if plugin_ns is not None:
self.logger.info(f"Stopping apps from namespace '{plugin_ns}' because the plugin failed")
app_names = self.get_namespace_apps(plugin_ns)
snapshot = self.stopped_plugin_apps.setdefault(plugin_ns, set())
snapshot |= app_names
deps = self.dependency_manager.app_deps.get_dependents(app_names)
update_actions.apps.term |= deps

async def _start_plugin_apps(self, plugin_ns: str | None, update_actions: UpdateActions):
"""If a plugin ever re-connects after the initial startup, the apps that use it's plugin
all need to be started. They should already have been stopped by the plugin disconnecting.
The apps that belong to the plugin are determined by namespace.

Apps whose ManagedObjects were deleted by the plugin failure are recovered from the
snapshot recorded in _stop_plugin_apps.
"""
if plugin_ns is not None:
self.logger.info(f"Processing restart for plugin namespace '{plugin_ns}'")
app_names = self.get_namespace_apps(plugin_ns)
if popped := self.stopped_plugin_apps.pop(plugin_ns, set()):
# Filter out apps whose config is gone, disabled, or not an app anymore
app_names |= {
name for name in popped
if isinstance(cfg := self.app_config.root.get(name), AppConfig) and not cfg.disable
} # fmt: skip
deps = self.dependency_manager.app_deps.get_dependents(app_names)
update_actions.apps.init |= deps

Expand All @@ -1208,7 +1226,7 @@ async def _stop_apps(self, update_actions: UpdateActions):

failed_to_stop = set() # stores apps that had a problem terminating
for app_name in stop_order:
successfully_stopped = await self.stop_app(app_name)
successfully_stopped = await self.stop_app(app_name, delete=True)
if successfully_stopped:
self.logger.info("Stopped app '%s'", app_name)
if app_name in indirect_stops:
Expand Down
33 changes: 28 additions & 5 deletions appdaemon/plugin_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ class PluginManagement:
"active": <bool>,
"name": <str>
}}``

The ``active`` flag is purely a runtime failure signal: True from registration, set False only by
``notify_plugin_stopped``, and set True again by ``notify_plugin_started``.
"""
required_meta = ["latitude", "longitude", "elevation", "time_zone"]
last_plugin_state: dict[str, datetime.datetime]
Expand Down Expand Up @@ -311,7 +314,7 @@ def __init__(self, ad: "AppDaemon", config: dict[str, PluginConfig]):
case {"object": PluginBase(name=str(existing_plugin))}:
raise ade.PluginNamespaceError(name, namespace, existing_plugin)

self.plugin_objs[namespace] = {"object": plugin, "active": False, "name": name}
self._register_plugin(name, plugin, namespace)

if self.AD.apps_enabled:
# Create app entry for the plugin so we can listen_state/event
Expand All @@ -327,6 +330,15 @@ def __init__(self, ad: "AppDaemon", config: dict[str, PluginConfig]):
self.logger.warning(traceback.format_exc())
self.logger.warning("-" * 60)

def _register_plugin(self, name: str, plugin: PluginBase, namespace: str) -> None:
"""Records an instantiated plugin in ``plugin_objs`` and seeds its state-refresh timestamp.
"""
self.plugin_objs[namespace] = {"object": plugin, "active": True, "name": name}
# Seed the refresh timestamp directly instead of via sched.get_now_sync(), which is
# deprecated-datetime based; only the elapsed delta is ever compared, so a naive UTC now
# is equivalent to what refresh_update_time records later
self.last_plugin_state[name] = datetime.datetime.now(tz=datetime.timezone.utc)

@property
def plugin_dir(self) -> Path:
"""Built-in plugin base directory"""
Expand Down Expand Up @@ -362,10 +374,17 @@ async def stop(self):
continue

self.logger.debug("Stopping plugin '%s'", name)
if asyncio.iscoroutinefunction(stop_func):
await stop_func()
else:
stop_func()
try:
if asyncio.iscoroutinefunction(stop_func):
await stop_func()
else:
stop_func()
except Exception:
self.logger.warning("-" * 60)
self.logger.warning("Error stopping plugin: %s - continuing shutdown", name)
self.logger.warning("-" * 60)
self.logger.warning(traceback.format_exc())
self.logger.warning("-" * 60)

await self.AD.callbacks.clear_callbacks(name)
self.AD.futures.cancel_futures(name)
Expand Down Expand Up @@ -437,6 +456,10 @@ def get_plugin_from_namespace(self, namespace: str) -> str:
else:
raise NameError(f"Bad namespace: {namespace}")

def any_plugin_inactive(self) -> bool:
"""Whether any configured plugin is currently marked inactive."""
return any(not plugin_cfg.get("active", True) for plugin_cfg in self.plugin_objs.values())

async def notify_plugin_stopped(self, name: str, namespace: str):
self.plugin_objs[namespace]["active"] = False
data = {"event_type": "plugin_stopped", "data": {"name": name}}
Expand Down
22 changes: 13 additions & 9 deletions appdaemon/plugins/hass/hassplugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@ def check_received_event(self, event: dict):
class HassPlugin(PluginBase):
config: HASSConfig
id: int
session: aiohttp.ClientSession
"""http connection pool for general use"""
ws: aiohttp.ClientWebSocketResponse
"""websocket dedicated for event loop"""
session: aiohttp.ClientSession | None
"""http connection pool for general use, ``None`` until a websocket connect succeeds."""
ws: aiohttp.ClientWebSocketResponse | None
"""websocket dedicated for event loop, ``None`` until a websocket connect succeeds"""
metadata: dict[str, Any]
services: dict[
str, # Domain
Expand Down Expand Up @@ -108,15 +108,19 @@ def __init__(self, ad: "AppDaemon", name: str, config: HASSConfig):
self.startup_conditions = []
self.maintenance_tasks = []

self.ws = None
self.session = None

self.service_logger = self.diag.getChild("services")
self.logger.info("HASS Plugin initialization complete")

async def stop(self):
await self.ws.close()
self.logger.debug("Websocket closed for '%s'", self.name)

await self.session.close()
self.logger.debug("aiohttp session closed for '%s'", self.name)
if self.ws is not None:
await self.ws.close()
self.logger.debug("Websocket closed for '%s'", self.name)
if self.session is not None:
await self.session.close()
self.logger.debug("aiohttp session closed for '%s'", self.name)

def _create_maintenance_task(self, coro: Coroutine, name: str) -> asyncio.Task:
task = self.AD.loop.create_task(coro, name=name)
Expand Down
4 changes: 2 additions & 2 deletions appdaemon/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,9 +455,9 @@ async def update_thread_info(self, thread_id, callback, app, type, uuid, silent)

if self.AD.real_time and duration >= self.AD.thread_duration_warning_threshold:
thread_name = f"thread.{thread_id}"
callback = await self.get_state(thread_name)
completed_callback = await self.get_state(thread_name)
self.logger.warning(
f"Excessive time spent in callback {callback}. "
f"Excessive time spent in callback {completed_callback}. "
f"Thread entity: '{thread_name}' - now complete after {format_timedelta(duration)} "
f"(limit={format_timedelta(self.AD.thread_duration_warning_threshold)})"
)
Expand Down
Loading