Skip to content
Merged
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
315 changes: 253 additions & 62 deletions appdaemon/adapi.py

Large diffs are not rendered by default.

85 changes: 44 additions & 41 deletions appdaemon/appdaemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import RLock
from typing import TYPE_CHECKING, Optional

from typing import TYPE_CHECKING, Any

from appdaemon.admin_loop import AdminLoop
from appdaemon.app_management import AppManagement
Expand All @@ -30,7 +29,8 @@


class AppDaemon(metaclass=Singleton):
"""Top-level container for the subsystem objects. This gets passed to the subsystem objects and stored in them as the ``self.AD`` attribute.
"""Top-level container for the subsystem objects. This gets passed to the subsystem objects and stored in them as
the ``self.AD`` attribute.

Asyncio:

Expand Down Expand Up @@ -81,36 +81,22 @@ class AppDaemon(metaclass=Singleton):
"""

# subsystems
app_management: AppManagement
callbacks: Callbacks = None
events: Events = None
futures: Futures
app_management: "AppManagement"
callbacks: "Callbacks"
events: "Events"
futures: "Futures"
logging: "Logging"
plugins: PluginManagement
scheduler: Scheduler
services: Services
sequences: Sequences
state: State
threading: Threading
thread_async: ThreadAsync = None
utility: Utility

# settings
app_dir: Path
"""Defined in the main YAML config under ``appdaemon.app_dir``. Defaults to ``./apps``
"""
config_dir: Path
"""Path to the AppDaemon configuration files. Defaults to the first folder that has ``./apps``

- ``~/.homeassistant``
- ``/etc/appdaemon``
"""
apps: bool
"""Flag for whether ``disable_apps`` was set in the AppDaemon config
"""

admin_loop: AdminLoop | None = None
http: Optional["HTTP"] = None
plugins: "PluginManagement"
scheduler: "Scheduler"
services: "Services"
sequences: "Sequences"
state: "State"
threading: "Threading"
thread_async: "ThreadAsync | None" = None
utility: "Utility"

admin_loop: "AdminLoop | None" = None
http: "HTTP | None" = None
global_lock: RLock = RLock()

# shut down flag
Expand All @@ -122,13 +108,13 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App
self.config = ad_config_model
self.booted = "booting"
self.logger = logging.get_logger()
self.logging.register_ad(self) # needs to go last to reference the config object
self.logging.register_ad(self) # needs to go last to reference the config object

self.global_vars = {}
self.global_vars: Any = {}
self.main_thread_id = threading.current_thread().ident

if not self.apps:
self.logging.log("INFO", "Apps are disabled")
self.logger.info("Apps are disabled")

# Initialize subsystems
self.callbacks = Callbacks(self)
Expand All @@ -143,7 +129,8 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App
assert self.config_dir is not None, "Config_dir not set. This is a development problem"
assert self.config_dir.exists(), f"{self.config_dir} does not exist"
assert os.access(
self.config_dir, os.R_OK | os.X_OK
self.config_dir,
os.R_OK | os.X_OK,
), f"{self.config_dir} does not have the right permissions"

# this will always be None because it never gets set in ad_kwargs in __main__.py
Expand All @@ -152,7 +139,8 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App
if not self.app_dir.exists():
self.app_dir.mkdir()
assert os.access(
self.app_dir, os.R_OK | os.W_OK | os.X_OK
self.app_dir,
os.R_OK | os.W_OK | os.X_OK,
), f"{self.app_dir} does not have the right permissions"

self.logger.info(f"Using {self.app_dir} as app_dir")
Expand All @@ -179,19 +167,25 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App
# Property definitions
#
@property
def admin_delay(self):
def admin_delay(self) -> int:
return self.config.admin_delay

@property
def api_port(self):
def api_port(self) -> int | None:
return self.config.api_port

@property
def app_dir(self):
def app_dir(self) -> Path:
"""Defined in the main YAML config under ``appdaemon.app_dir``. Defaults to ``./apps``"""
return self.config.app_dir

@app_dir.setter
def app_dir(self, path: os.PathLike) -> None:
self.config.app_dir = Path(path)

@property
def apps(self):
"""Flag for whether ``disable_apps`` was set in the AppDaemon config"""
return not self.config.disable_apps

@property
Expand All @@ -204,8 +198,17 @@ def check_app_updates_profile(self):

@property
def config_dir(self):
"""Path to the AppDaemon configuration files. Defaults to the first folder that has ``./apps``

- ``~/.homeassistant``
- ``/etc/appdaemon``
"""
return self.config.config_dir

@config_dir.setter
def config_dir(self, path: os.PathLike) -> None:
self.config.config_dir = Path(path)

@property
def config_file(self):
return self.config.config_file
Expand Down Expand Up @@ -366,7 +369,7 @@ def terminate(self):
def register_http(self, http: "HTTP"):
"""Sets the ``self.http`` attribute with a :class:`~.http.HTTP` object and starts the admin loop."""

self.http: "HTTP" = http
self.http = http
# Create admin loop

if http.old_admin is not None or http.admin is not None:
Expand Down
20 changes: 11 additions & 9 deletions appdaemon/events.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from collections.abc import Iterable
import datetime
import json
import traceback
import uuid
from collections.abc import Callable, Iterable
from copy import deepcopy
from logging import Logger
from typing import TYPE_CHECKING, Any, Protocol
from collections.abc import Callable

import appdaemon.utils as utils

from .plugin_management import PluginBase

if TYPE_CHECKING:
from appdaemon.appdaemon import AppDaemon

Expand Down Expand Up @@ -190,13 +191,14 @@ async def fire_event(self, namespace: str, event: str, **kwargs):

self.logger.debug("fire_plugin_event() %s %s %s", namespace, event, kwargs)
plugin = self.AD.plugins.get_plugin_object(namespace)

if hasattr(plugin, "fire_plugin_event"):
# We assume that the event will come back to us via the plugin
return await plugin.fire_plugin_event(event, namespace, **kwargs)
else:
# Just fire the event locally
await self.AD.events.process_event(namespace, {"event_type": event, "data": kwargs})
match plugin:
case PluginBase() as plugin:
if hasattr(plugin, "fire_plugin_event"):
# We assume that the event will come back to us via the plugin
return await plugin.fire_plugin_event(event, namespace, **kwargs)
else:
# Just fire the event locally
await self.AD.events.process_event(namespace, {"event_type": event, "data": kwargs})

async def process_event(self, namespace: str, data: dict[str, Any]):
"""Processes an event that has been received either locally or from a plugin.
Expand Down
12 changes: 9 additions & 3 deletions appdaemon/plugin_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import importlib
import sys
import traceback
from collections.abc import Generator, Iterable
from collections.abc import Generator, Iterable, Mapping
from logging import Logger
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Type
Expand Down Expand Up @@ -138,7 +138,13 @@ async def remove_entity(self, namespace: str, entity: str) -> None:
# pass

# @abc.abstractmethod
async def fire_plugin_event(self):
async def fire_plugin_event(
self,
event: str,
namespace: str,
timeout: str | int | float | datetime.timedelta | None = None,
**kwargs: Any,
) -> dict[str, Any] | None: # fmt: skip
raise NotImplementedError

@utils.warning_decorator(error_text="Unexpected error during notify_plugin_started()")
Expand Down Expand Up @@ -233,7 +239,7 @@ class PluginManagement:
"""Flag for if PluginManagement should be shutting down
"""

def __init__(self, ad: "AppDaemon", config: dict[str, PluginConfig]):
def __init__(self, ad: "AppDaemon", config: Mapping[str, PluginConfig]):
self.AD = ad
self.config = config
self.stopping = False
Expand Down
8 changes: 7 additions & 1 deletion appdaemon/plugins/hass/hassplugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,13 @@ async def call_plugin_service(
#

@hass_check
async def fire_plugin_event(self, event, namespace, timeout: float | None = None, **kwargs) -> dict | None:
async def fire_plugin_event(
self,
event: str,
namespace: str,
timeout: str | int | float | datetime.timedelta | None = None,
**kwargs: Any,
) -> dict[str, Any] | None: # fmt: skip
# if we get a request for not our namespace something has gone very wrong
assert namespace == self.namespace

Expand Down
13 changes: 7 additions & 6 deletions appdaemon/state.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from datetime import timedelta
import threading
import traceback
import uuid
from copy import copy, deepcopy
from datetime import timedelta
from logging import Logger
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Set, Union, overload
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Set, overload

from . import exceptions as ade
from . import utils
Expand Down Expand Up @@ -74,8 +74,8 @@ async def add_namespace(
namespace: str,
writeback: str,
persist: bool,
name: str = None
) -> Union[bool, Path]:
name: str | None = None,
) -> Path | bool | None: # fmt: skip
"""Used to Add Namespaces from Apps"""

if self.namespace_exists(namespace):
Expand Down Expand Up @@ -533,11 +533,12 @@ async def add_entity(
self,
namespace: str,
entity: str,
state: str | dict,
state: str | dict[str, Any],
attributes: Optional[dict] = None
):
) -> None:
"""Adds an entity to the internal state registry and fires the ``__AD_ENTITY_ADDED`` event"""
if self.entity_exists(namespace, entity):
self.logger.warning("%s already exists, will not be adding it", entity)
return

state = {
Expand Down
2 changes: 1 addition & 1 deletion appdaemon/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ def parse_timedelta(s: str | int | float | timedelta | None) -> timedelta:
case int() | float():
return timedelta(seconds=s)
case str():
parts = tuple(float(p.strip()) for p in re.split(r"[^\d]+", s))
parts = tuple(float(p.strip()) for p in re.split(r"[^\d\.]+", s))
match len(parts):
case 1:
return timedelta(seconds=parts[0])
Expand Down
2 changes: 1 addition & 1 deletion appdaemon/version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version__ = "4.5.8"
__version__ = "4.5.9"
__version_comments__ = ""
22 changes: 21 additions & 1 deletion docs/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
# Change Log

## 4.5.8
## 4.5.9

**Features**

None

**Fixes**

- Passing through `timeout` kwarg in `dash_navigate` and `fire_event`
- Fixed a bug with `parse_timedelta` in cases like `"00:2.5"`
- Minor type fixes

**Breaking Changes**

None

**Changes in Behavior**

None

## 4.5.8 (2025-06-03)

**Features**

Expand Down
Loading