From 745e7f97bb69e49222fb127bffa6b10a4924d1aa Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:24:38 -0500 Subject: [PATCH 01/14] allowing generic plugins properly --- appdaemon/models/config/appdaemon.py | 19 +++++++++++++------ appdaemon/models/config/plugin.py | 10 ++++++++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/appdaemon/models/config/appdaemon.py b/appdaemon/models/config/appdaemon.py index 17efcdddd..dc6a12ff0 100644 --- a/appdaemon/models/config/appdaemon.py +++ b/appdaemon/models/config/appdaemon.py @@ -10,17 +10,23 @@ from typing_extensions import deprecated from appdaemon import utils -from appdaemon.models.config.http import CoercedPath +from appdaemon.version import __version__ -from ...models.config.plugin import HASSConfig, MQTTConfig -from ...version import __version__ +from .common import CoercedPath from .misc import FilterConfig, NamespaceConfig +from .plugin import HASSConfig, MQTTConfig, PluginConfig + def plugin_discriminator(plugin): if isinstance(plugin, dict): - return plugin["type"].lower() + type_ = plugin["type"].lower() + else: + type_ = plugin.type + + if type_ in ("hass", "mqtt"): + return type_ else: - plugin.type + return "generic" class ModuleLoggingLevels(RootModel): @@ -35,7 +41,7 @@ class AppDaemonConfig(BaseModel, extra="allow"): plugins: dict[ str, Annotated[ - Annotated[HASSConfig, Tag("hass")] | Annotated[MQTTConfig, Tag("mqtt")], + Annotated[HASSConfig, Tag("hass")] | Annotated[MQTTConfig, Tag("mqtt")] | Annotated[PluginConfig, Tag("generic")], Discriminator(plugin_discriminator), ], ] = Field(default_factory=dict) @@ -155,6 +161,7 @@ def convert_loglevel(cls, v: str | int): @field_validator("plugins", mode="before") @classmethod def validate_plugins(cls, v: Any): + # This is needed to set the name field in each plugin config to the name of the key used to define it. for n in set(v.keys()): v[n]["name"] = n return v diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index 340fec91e..ac7f18a67 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -8,10 +8,10 @@ from pydantic import BaseModel, BeforeValidator, Field, SecretBytes, SecretStr, ValidationInfo, field_validator, model_validator from typing_extensions import deprecated -from .common import CoercedPath - from appdaemon import utils +from .common import CoercedPath + class PluginConfig(BaseModel, extra="allow"): type: str @@ -60,6 +60,12 @@ def custom_validator(self): @property def disabled(self) -> bool: return self.disable + + def __getitem__(self, item: str) -> Any: + """Allows accessing plugin config attributes as if it were a dict.""" + if item in self.model_fields_set: + return getattr(self, item) + raise KeyError(f"'{item}' not found in plugin config '{self.type}'") class StartupState(BaseModel): From fbb4087c3b06ea0f801209fbf1eb4c04081888e8 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:29:05 -0500 Subject: [PATCH 02/14] linting --- appdaemon/models/config/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index ac7f18a67..6b5b6b8dd 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -60,7 +60,7 @@ def custom_validator(self): @property def disabled(self) -> bool: return self.disable - + def __getitem__(self, item: str) -> Any: """Allows accessing plugin config attributes as if it were a dict.""" if item in self.model_fields_set: From fe5400cee2f3112c8f2e66e1a3547979e314b0d1 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Thu, 25 Sep 2025 19:11:15 -0500 Subject: [PATCH 03/14] docstrings --- appdaemon/models/config/appdaemon.py | 2 ++ appdaemon/models/config/plugin.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/appdaemon/models/config/appdaemon.py b/appdaemon/models/config/appdaemon.py index dc6a12ff0..ebd020167 100644 --- a/appdaemon/models/config/appdaemon.py +++ b/appdaemon/models/config/appdaemon.py @@ -97,6 +97,8 @@ class AppDaemonConfig(BaseModel, extra="allow"): import_paths: list[Path] = Field(default_factory=list) namespaces: dict[str, NamespaceConfig] = Field(default_factory=dict) exclude_dirs: list[str] = Field(default_factory=list) + """List of directory names to exclude when searching for apps. This will always include __pycache__, build, and + .venv""" cert_verify: bool = True disable_apps: bool = False suppress_log_messages: bool = False diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index 6b5b6b8dd..d95bc5fe3 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -20,7 +20,7 @@ class PluginConfig(BaseModel, extra="allow"): disable: bool = False persist_entities: bool = False refresh_delay: Annotated[timedelta, BeforeValidator(lambda v: timedelta(minutes=v))] = timedelta(minutes=10) - """Delay between refreshes of the complete plugin state in the utility loop""" + """Delay between refreshes of the complete plugin state in the utility loop. The units are in minutes.""" refresh_timeout: int = 30 use_dictionary_unpacking: bool = True From e1529e6e5c4c8524be921b2221f94c521b69a0f3 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Thu, 25 Sep 2025 19:11:32 -0500 Subject: [PATCH 04/14] fixed debug method --- appdaemon/models/config/yaml.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/appdaemon/models/config/yaml.py b/appdaemon/models/config/yaml.py index 234f6105b..9b433f773 100644 --- a/appdaemon/models/config/yaml.py +++ b/appdaemon/models/config/yaml.py @@ -4,7 +4,8 @@ from pydantic import BaseModel, model_validator from typing_extensions import deprecated -from ... import utils +from appdaemon import utils + from .appdaemon import AppDaemonConfig from .dashboard import DashboardConfig from .http import HTTPConfig @@ -24,9 +25,13 @@ class MainConfig(BaseModel): @classmethod def from_config_file(cls, file: str | Path): - config = utils.read_config_file(file) - config["appdaemon"]["config_file"] = file - return cls.model_validate(config) + file = file if isinstance(file, Path) else Path(file) + raw_cfg = utils.read_config_file(file) + match raw_cfg: + case {"appdaemon": dict() as cfg}: + cfg["config_file"] = file + cfg["config_dir"] = file.parent + return cls.model_validate(raw_cfg) @classmethod def from_cli_kwargs(cls, cli_kwargs: AppDaemonCLIKwargs): From 47c9f765e15ee7a11cf31c1c313aff7980a79861 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Thu, 25 Sep 2025 19:16:48 -0500 Subject: [PATCH 05/14] types --- appdaemon/models/config/appdaemon.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/appdaemon/models/config/appdaemon.py b/appdaemon/models/config/appdaemon.py index ebd020167..332820f5b 100644 --- a/appdaemon/models/config/appdaemon.py +++ b/appdaemon/models/config/appdaemon.py @@ -141,24 +141,27 @@ class AppDaemonConfig(BaseModel, extra="allow"): @field_validator("config_dir", mode="after") @classmethod - def convert_to_absolute(cls, v: Path): + def convert_to_absolute(cls, v: Path) -> Path: return v.resolve() @field_validator("exclude_dirs", mode="after") @classmethod - def add_default_exclusions(cls, v: list[Path]): + def add_default_exclusions(cls, v: list[str]) -> list[str]: v.extend(["__pycache__", "build", ".venv"]) return v @field_validator("loglevel", mode="before") @classmethod - def convert_loglevel(cls, v: str | int): - if isinstance(v, int): - return logging._levelToName[int] - elif isinstance(v, str): - v = v.upper() - assert v in logging._nameToLevel, f"Invalid log level: {v}" - return v + def convert_loglevel(cls, lvl: str | int) -> str: + match lvl: + case int(): + return logging._levelToName[lvl] + case str(): + lvl = lvl.upper() + assert lvl in logging._nameToLevel, f"Invalid log level: {lvl}" + return lvl + case _: + raise ValueError(f"Invalid log level: {lvl}") @field_validator("plugins", mode="before") @classmethod From 85ca536b75567440cd53850aedcd9a94ad7ac940 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Fri, 26 Sep 2025 00:16:36 -0500 Subject: [PATCH 06/14] plugin error handling --- appdaemon/exceptions.py | 2 +- appdaemon/plugin_management.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/appdaemon/exceptions.py b/appdaemon/exceptions.py index 6a243610d..e1c9bd266 100644 --- a/appdaemon/exceptions.py +++ b/appdaemon/exceptions.py @@ -50,7 +50,7 @@ def exception_handler(appdaemon: "AppDaemon", loop: asyncio.AbstractEventLoop, c ) -def user_exception_block(logger: Logger, exception: AppDaemonException, app_dir: Path, header: str | None = None): +def user_exception_block(logger: Logger, exception: BaseException, app_dir: Path, header: str | None = None): """Function to generate a user-friendly block of text for an exception. Gets the whole chain of exception causes to decide what to do. """ width = 75 diff --git a/appdaemon/plugin_management.py b/appdaemon/plugin_management.py index 943c0a238..b9354922b 100644 --- a/appdaemon/plugin_management.py +++ b/appdaemon/plugin_management.py @@ -225,7 +225,7 @@ class PluginManagement: """Dictionary storing the metadata for the loaded plugins. {: } """ - plugin_objs: Dict[str, PluginBase] + plugin_objs: Dict[str, dict[str, PluginBase | bool | str]] """Dictionary storing the instantiated plugin objects. ``{: { "object": , @@ -310,7 +310,11 @@ def __init__(self, ad: "AppDaemon", config: Mapping[str, PluginConfig]): ) self.AD.loop.create_task(plugin.get_updates()) + except ModuleNotFoundError: + self.error.warning(f"Error loading plugin: {name} - ignoring") + # ade.user_exception_block(self.error, exc, self.AD.app_dir, f"Error loading plugin: {name} - ignoring") except Exception: + self.logger.warning("-" * 60) self.logger.warning("error loading plugin: %s - ignoring", name) self.logger.warning("-" * 60) self.logger.warning(traceback.format_exc()) @@ -450,9 +454,10 @@ def get_config_for_namespace(self, namespace: str) -> PluginConfig: @property def active_plugins(self) -> Generator[tuple[PluginBase, PluginConfig], None, None]: for namespace, plugin_cfg in self.plugin_objs.items(): - if plugin_cfg["active"]: - cfg = self.get_config_for_namespace(namespace) - yield plugin_cfg["object"], cfg + match plugin_cfg: + case {"object": PluginBase() as obj, "active": True}: + cfg = self.get_config_for_namespace(namespace) + yield obj, cfg async def refresh_update_time(self, plugin_name: str): """Updates the internal time for when the plugin's state was last updated""" From e0e7d45fc98155b7608e9d47b6b06a8dedab00bf Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Fri, 26 Sep 2025 00:17:15 -0500 Subject: [PATCH 07/14] common type updates --- appdaemon/models/config/common.py | 51 +++++++++++-------------------- appdaemon/models/config/yaml.py | 2 ++ 2 files changed, 19 insertions(+), 34 deletions(-) diff --git a/appdaemon/models/config/common.py b/appdaemon/models/config/common.py index a8b0bc875..4fbf00d9a 100644 --- a/appdaemon/models/config/common.py +++ b/appdaemon/models/config/common.py @@ -1,49 +1,32 @@ from datetime import timedelta from pathlib import Path -from typing import Annotated, Any, Literal +from typing import Annotated, Literal from pydantic import BeforeValidator, PlainSerializer, ValidationError +from appdaemon.utils import parse_timedelta + LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +BoolNum = Annotated[bool, BeforeValidator(lambda v: False if int(v) == 0 else True)] +TimeType = Annotated[timedelta, BeforeValidator(parse_timedelta), PlainSerializer(lambda td: td.total_seconds())] + -def coerce_path(v: Any) -> Path | Literal["STDOUT", "STDERR"]: +def coerce_path(v: str | Path) -> Path: """Coerce a string or Path to a resolved Path.""" match v: - case Path(): - pass - case "STDOUT" | "STDERR": - return v - case str(): - v = Path(v) + case str() as path_string: + return Path(path_string) + case Path() as path: + return path case _: raise ValidationError(f"Invalid type for path: {v}") - return v.resolve() if not v.is_absolute() else v - - -CoercedPath = Annotated[Path | Literal["STDOUT", "STDERR"], BeforeValidator(coerce_path)] - -def validate_timedelta(v: Any): - match v: - case str(): - parts = tuple(map(float, v.split(":"))) - match len(parts): - case 1: - return timedelta(seconds=parts[0]) - case 2: - return timedelta(minutes=parts[0], seconds=parts[1]) - case 3: - return timedelta(hours=parts[0], minutes=parts[1], seconds=parts[2]) - case _: - raise ValidationError(f"Invalid timedelta format: {v}") - case int() | float(): - return timedelta(seconds=v) - case _: - raise ValidationError(f"Invalid type for timedelta: {v}") - - -TimeType = Annotated[timedelta, BeforeValidator(validate_timedelta), PlainSerializer(lambda td: td.total_seconds())] +def coerce_abs_path(v: str | Path) -> Path: + """Coerce a string or Path to a resolved Path.""" + return coerce_path(v).absolute() -BoolNum = Annotated[bool, BeforeValidator(lambda v: False if int(v) == 0 else True)] +CoercedPath = Annotated[Path, BeforeValidator(coerce_abs_path)] +CoercedRelPath = Annotated[Path, BeforeValidator(coerce_path)] +LogPath = Annotated[Literal["STDOUT", "STDERR"], BeforeValidator(lambda s: s.upper())] | CoercedPath diff --git a/appdaemon/models/config/yaml.py b/appdaemon/models/config/yaml.py index 9b433f773..b48ea22f3 100644 --- a/appdaemon/models/config/yaml.py +++ b/appdaemon/models/config/yaml.py @@ -32,6 +32,8 @@ def from_config_file(cls, file: str | Path): cfg["config_file"] = file cfg["config_dir"] = file.parent return cls.model_validate(raw_cfg) + case _: + raise ValueError(f"Invalid configuration file: {file}") @classmethod def from_cli_kwargs(cls, cli_kwargs: AppDaemonCLIKwargs): From 3690aac09b4cc94be13d3c0372fe36cb7144c990 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Fri, 26 Sep 2025 00:26:56 -0500 Subject: [PATCH 08/14] type updates --- appdaemon/admin_loop.py | 2 +- appdaemon/appdaemon.py | 15 ------- appdaemon/models/config/appdaemon.py | 65 +++++++++++----------------- appdaemon/models/config/common.py | 21 ++------- appdaemon/models/config/dashboard.py | 2 +- appdaemon/models/config/log.py | 4 +- appdaemon/plugin_management.py | 2 +- appdaemon/utility_loop.py | 4 +- 8 files changed, 35 insertions(+), 80 deletions(-) diff --git a/appdaemon/admin_loop.py b/appdaemon/admin_loop.py index 3acfae44e..7c256a2ab 100644 --- a/appdaemon/admin_loop.py +++ b/appdaemon/admin_loop.py @@ -33,4 +33,4 @@ async def loop(self): await self.AD.threading.get_callback_update() await self.AD.threading.get_q_update() - await asyncio.sleep(self.AD.admin_delay) + await asyncio.sleep(self.AD.config.admin_delay.total_seconds()) diff --git a/appdaemon/appdaemon.py b/appdaemon/appdaemon.py index 70450cf85..fc8d954f4 100755 --- a/appdaemon/appdaemon.py +++ b/appdaemon/appdaemon.py @@ -166,9 +166,6 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App # # Property definitions # - @property - def admin_delay(self) -> int: - return self.config.admin_delay @property def api_port(self) -> int | None: @@ -253,14 +250,6 @@ def loglevel(self): def longitude(self): return self.config.longitude - @property - def max_clock_skew(self): - return self.config.max_clock_skew - - @property - def max_utility_skew(self): - return self.config.max_utility_skew - @property def missing_app_warnings(self): return self.config.invalid_config_warnings @@ -331,10 +320,6 @@ def use_stream(self): def write_toml(self): return self.config.write_toml - @property - def utility_delay(self): - return self.config.utility_delay - def stop(self): """Called by the signal handler to shut AD down. diff --git a/appdaemon/models/config/appdaemon.py b/appdaemon/models/config/appdaemon.py index 332820f5b..4a806d038 100644 --- a/appdaemon/models/config/appdaemon.py +++ b/appdaemon/models/config/appdaemon.py @@ -5,28 +5,30 @@ from typing import Annotated, Any, Literal import pytz -from pydantic import BaseModel, BeforeValidator, ConfigDict, Discriminator, Field, RootModel, SecretStr, Tag, field_validator, model_validator +from pydantic import BaseModel, BeforeValidator, ConfigDict, Discriminator, Field, PlainSerializer, RootModel, SecretStr, Tag, field_validator, model_validator from pytz.tzinfo import BaseTzInfo from typing_extensions import deprecated -from appdaemon import utils from appdaemon.version import __version__ -from .common import CoercedPath +from .common import CoercedPath, CoercedRelPath, TimeType from .misc import FilterConfig, NamespaceConfig from .plugin import HASSConfig, MQTTConfig, PluginConfig -def plugin_discriminator(plugin): - if isinstance(plugin, dict): - type_ = plugin["type"].lower() - else: - type_ = plugin.type +def plugin_discriminator(plugin: dict[str, Any] | PluginConfig) -> Literal["hass", "mqtt", "custom"]: + match plugin: + case {"type": str(t)} | PluginConfig(type=str(t)): + match t.lower(): + case ("hass" | "mqtt" | "custom") as type_: + return type_ + return "custom" - if type_ in ("hass", "mqtt"): - return type_ - else: - return "generic" + +DiscriminatedPluginConfig = Annotated[ + Annotated[HASSConfig, Tag("hass")] | Annotated[MQTTConfig, Tag("mqtt")] | Annotated[PluginConfig, Tag("custom")], + Discriminator(plugin_discriminator), +] class ModuleLoggingLevels(RootModel): @@ -37,18 +39,12 @@ class AppDaemonConfig(BaseModel, extra="allow"): latitude: float longitude: float elevation: int - time_zone: Annotated[BaseTzInfo, BeforeValidator(pytz.timezone)] - plugins: dict[ - str, - Annotated[ - Annotated[HASSConfig, Tag("hass")] | Annotated[MQTTConfig, Tag("mqtt")] | Annotated[PluginConfig, Tag("generic")], - Discriminator(plugin_discriminator), - ], - ] = Field(default_factory=dict) + time_zone: Annotated[BaseTzInfo, BeforeValidator(pytz.timezone), PlainSerializer(lambda tz: tz.zone)] + plugins: dict[str, DiscriminatedPluginConfig] = Field(default_factory=dict) - config_dir: Path - config_file: Path - app_dir: Path = "./apps" + config_dir: CoercedPath + config_file: CoercedPath + app_dir: CoercedRelPath write_toml: bool = False ext: Literal[".yaml", ".toml"] = ".yaml" @@ -58,10 +54,9 @@ class AppDaemonConfig(BaseModel, extra="allow"): starttime: datetime | None = None endtime: datetime | None = None timewarp: float = 1 - max_clock_skew: int = 1 loglevel: str = "INFO" - module_debug: ModuleLoggingLevels = Field(default_factory=dict) + module_debug: ModuleLoggingLevels = Field(default_factory=ModuleLoggingLevels) api_port: int | None = None api_key: SecretStr | None = None @@ -69,14 +64,11 @@ class AppDaemonConfig(BaseModel, extra="allow"): api_ssl_key: CoercedPath | None = None stop_function: Callable | None = None - utility_delay: int = 1 - admin_delay: int = 1 + utility_delay: TimeType = timedelta(seconds=1) + admin_delay: TimeType = timedelta(seconds=1) plugin_performance_update: int = 10 """How often in seconds to update the admin entities with the plugin performance data""" - max_utility_skew: Annotated[ - timedelta, - BeforeValidator(utils.parse_timedelta) - ] = Field(default_factory=lambda: timedelta(seconds=2)) + max_utility_skew: TimeType = timedelta(seconds=2) check_app_updates_profile: bool = False production_mode: bool = False invalid_config_warnings: bool = True @@ -85,10 +77,7 @@ class AppDaemonConfig(BaseModel, extra="allow"): qsize_warning_threshold: int = 50 qsize_warning_step: int = 60 qsize_warning_iterations: int = 10 - internal_function_timeout: Annotated[ - timedelta, - BeforeValidator(utils.parse_timedelta) - ] = Field(default_factory=lambda: timedelta(seconds=60)) + internal_function_timeout: TimeType = timedelta(seconds=60) """Timeout for internal function calls. This determines how long apps can wait in their thread for an async function to complete in the main thread.""" use_dictionary_unpacking: Annotated[bool, deprecated("This option is no longer necessary")] = False @@ -136,14 +125,10 @@ class AppDaemonConfig(BaseModel, extra="allow"): arbitrary_types_allowed=True, extra="allow", validate_assignment=True, + validate_default=True, ) ad_version: str = __version__ - @field_validator("config_dir", mode="after") - @classmethod - def convert_to_absolute(cls, v: Path) -> Path: - return v.resolve() - @field_validator("exclude_dirs", mode="after") @classmethod def add_default_exclusions(cls, v: list[str]) -> list[str]: diff --git a/appdaemon/models/config/common.py b/appdaemon/models/config/common.py index 4fbf00d9a..033309342 100644 --- a/appdaemon/models/config/common.py +++ b/appdaemon/models/config/common.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Annotated, Literal -from pydantic import BeforeValidator, PlainSerializer, ValidationError +from pydantic import BeforeValidator, PlainSerializer from appdaemon.utils import parse_timedelta @@ -12,21 +12,6 @@ TimeType = Annotated[timedelta, BeforeValidator(parse_timedelta), PlainSerializer(lambda td: td.total_seconds())] -def coerce_path(v: str | Path) -> Path: - """Coerce a string or Path to a resolved Path.""" - match v: - case str() as path_string: - return Path(path_string) - case Path() as path: - return path - case _: - raise ValidationError(f"Invalid type for path: {v}") - -def coerce_abs_path(v: str | Path) -> Path: - """Coerce a string or Path to a resolved Path.""" - return coerce_path(v).absolute() - - -CoercedPath = Annotated[Path, BeforeValidator(coerce_abs_path)] -CoercedRelPath = Annotated[Path, BeforeValidator(coerce_path)] +CoercedPath = Annotated[Path, BeforeValidator(lambda p: Path(p).resolve())] +CoercedRelPath = Annotated[Path, BeforeValidator(Path)] LogPath = Annotated[Literal["STDOUT", "STDERR"], BeforeValidator(lambda s: s.upper())] | CoercedPath diff --git a/appdaemon/models/config/dashboard.py b/appdaemon/models/config/dashboard.py index 60e755d3a..952c357c1 100644 --- a/appdaemon/models/config/dashboard.py +++ b/appdaemon/models/config/dashboard.py @@ -11,4 +11,4 @@ class DashboardConfig(BaseModel): force_compile: BoolNum = False compile_on_start: BoolNum = False profile_dashboard: bool = False - dashboard: bool + dashboard: bool = False diff --git a/appdaemon/models/config/log.py b/appdaemon/models/config/log.py index 6f63ed401..cfcda0150 100644 --- a/appdaemon/models/config/log.py +++ b/appdaemon/models/config/log.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field, RootModel -from .common import CoercedPath, LogLevel, TimeType +from .common import LogPath, LogLevel, TimeType SYSTEM_LOG_NAME_MAP = { "main_log": 'AppDaemon', @@ -14,7 +14,7 @@ class AppDaemonLogConfig(BaseModel): - filename: CoercedPath = "STDOUT" + filename: LogPath = "STDOUT" name: str | None = None level: LogLevel = 'INFO' log_generations: int = 3 diff --git a/appdaemon/plugin_management.py b/appdaemon/plugin_management.py index b9354922b..53a733c4e 100644 --- a/appdaemon/plugin_management.py +++ b/appdaemon/plugin_management.py @@ -473,7 +473,7 @@ async def update_plugin_state(self): try: state = await asyncio.wait_for( plugin.get_complete_state(), - timeout=cfg.refresh_timeout + timeout=cfg.refresh_timeout.total_seconds(), ) except asyncio.TimeoutError: self.logger.warning( diff --git a/appdaemon/utility_loop.py b/appdaemon/utility_loop.py index 425216ee9..207dcd850 100644 --- a/appdaemon/utility_loop.py +++ b/appdaemon/utility_loop.py @@ -230,7 +230,7 @@ async def loop(self): utils.format_timedelta(check_app_duration), utils.format_timedelta(other_duration), ) - if self.AD.sched.realtime and loop_duration > self.AD.max_utility_skew: + if self.AD.sched.realtime and loop_duration > self.AD.config.max_utility_skew: self.logger.warning( "Excessive time spent in utility loop: %s, %s in check_app_updates(), %s in other", utils.format_timedelta(loop_duration), @@ -241,7 +241,7 @@ async def loop(self): self.logger.info("Profile information for Utility Loop") self.logger.info(self.AD.app_management.check_app_updates_profile_stats) else: - await asyncio.sleep(self.AD.utility_delay) + await asyncio.sleep(self.AD.config.utility_delay.total_seconds()) # # Shutting down now From 75b45d55a92c7b571b115915bf4b67bdd1110cf8 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Fri, 26 Sep 2025 00:30:17 -0500 Subject: [PATCH 09/14] rename --- appdaemon/models/config/appdaemon.py | 10 +++++----- appdaemon/models/config/common.py | 2 +- appdaemon/models/config/log.py | 6 +++--- appdaemon/models/config/plugin.py | 12 ++++++------ appdaemon/models/config/sequence.py | 8 ++++---- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/appdaemon/models/config/appdaemon.py b/appdaemon/models/config/appdaemon.py index 4a806d038..943ea8630 100644 --- a/appdaemon/models/config/appdaemon.py +++ b/appdaemon/models/config/appdaemon.py @@ -11,7 +11,7 @@ from appdaemon.version import __version__ -from .common import CoercedPath, CoercedRelPath, TimeType +from .common import CoercedPath, CoercedRelPath, ParsedTimedelta from .misc import FilterConfig, NamespaceConfig from .plugin import HASSConfig, MQTTConfig, PluginConfig @@ -64,11 +64,11 @@ class AppDaemonConfig(BaseModel, extra="allow"): api_ssl_key: CoercedPath | None = None stop_function: Callable | None = None - utility_delay: TimeType = timedelta(seconds=1) - admin_delay: TimeType = timedelta(seconds=1) + utility_delay: ParsedTimedelta = timedelta(seconds=1) + admin_delay: ParsedTimedelta = timedelta(seconds=1) plugin_performance_update: int = 10 """How often in seconds to update the admin entities with the plugin performance data""" - max_utility_skew: TimeType = timedelta(seconds=2) + max_utility_skew: ParsedTimedelta = timedelta(seconds=2) check_app_updates_profile: bool = False production_mode: bool = False invalid_config_warnings: bool = True @@ -77,7 +77,7 @@ class AppDaemonConfig(BaseModel, extra="allow"): qsize_warning_threshold: int = 50 qsize_warning_step: int = 60 qsize_warning_iterations: int = 10 - internal_function_timeout: TimeType = timedelta(seconds=60) + internal_function_timeout: ParsedTimedelta = timedelta(seconds=60) """Timeout for internal function calls. This determines how long apps can wait in their thread for an async function to complete in the main thread.""" use_dictionary_unpacking: Annotated[bool, deprecated("This option is no longer necessary")] = False diff --git a/appdaemon/models/config/common.py b/appdaemon/models/config/common.py index 033309342..0c1462401 100644 --- a/appdaemon/models/config/common.py +++ b/appdaemon/models/config/common.py @@ -9,7 +9,7 @@ LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] BoolNum = Annotated[bool, BeforeValidator(lambda v: False if int(v) == 0 else True)] -TimeType = Annotated[timedelta, BeforeValidator(parse_timedelta), PlainSerializer(lambda td: td.total_seconds())] +ParsedTimedelta = Annotated[timedelta, BeforeValidator(parse_timedelta), PlainSerializer(lambda td: td.total_seconds())] CoercedPath = Annotated[Path, BeforeValidator(lambda p: Path(p).resolve())] diff --git a/appdaemon/models/config/log.py b/appdaemon/models/config/log.py index cfcda0150..791e6138d 100644 --- a/appdaemon/models/config/log.py +++ b/appdaemon/models/config/log.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field, RootModel -from .common import LogPath, LogLevel, TimeType +from .common import LogPath, LogLevel, ParsedTimedelta SYSTEM_LOG_NAME_MAP = { "main_log": 'AppDaemon', @@ -22,8 +22,8 @@ class AppDaemonLogConfig(BaseModel): format_: str = Field(default="{asctime} {levelname} {appname}: {message}", alias="format") date_format: str = "%Y-%m-%d %H:%M:%S.%f" filter_threshold: int = 1 - filter_timeout: TimeType = timedelta(seconds=0.9) - filter_repeat_delay: TimeType = timedelta(seconds=5.0) + filter_timeout: ParsedTimedelta = timedelta(seconds=0.9) + filter_repeat_delay: ParsedTimedelta = timedelta(seconds=5.0) class AppDaemonFullLogConfig(RootModel): diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index d95bc5fe3..3f4374b89 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -10,7 +10,7 @@ from appdaemon import utils -from .common import CoercedPath +from .common import CoercedPath, ParsedTimedelta class PluginConfig(BaseModel, extra="allow"): @@ -19,9 +19,9 @@ class PluginConfig(BaseModel, extra="allow"): """Gets set by a field_validator in the AppDaemonConfig""" disable: bool = False persist_entities: bool = False - refresh_delay: Annotated[timedelta, BeforeValidator(lambda v: timedelta(minutes=v))] = timedelta(minutes=10) - """Delay between refreshes of the complete plugin state in the utility loop. The units are in minutes.""" - refresh_timeout: int = 30 + refresh_delay: ParsedTimedelta = timedelta(minutes=10) + """Delay between refreshes of the complete plugin state in the utility loop.""" + refresh_timeout: ParsedTimedelta = timedelta(seconds=30) use_dictionary_unpacking: bool = True # Used by the AppDaemon internals to import the plugins. @@ -30,8 +30,8 @@ class PluginConfig(BaseModel, extra="allow"): api_module: str = None api_class: str = None - connect_timeout: int | float = 1.0 - reconnect_delay: int | float = 5.0 + connect_timeout: ParsedTimedelta = timedelta(seconds=1) + reconnect_delay: ParsedTimedelta = timedelta(seconds=5) namespace: str = "default" namespaces: list[str] = Field(default_factory=list) diff --git a/appdaemon/models/config/sequence.py b/appdaemon/models/config/sequence.py index 55e5a1913..7e07788a1 100644 --- a/appdaemon/models/config/sequence.py +++ b/appdaemon/models/config/sequence.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, BeforeValidator, Discriminator, Field, RootModel, Tag, WrapSerializer, model_validator from ... import exceptions as ade -from .common import TimeType +from .common import ParsedTimedelta class SequenceStep(BaseModel): @@ -13,18 +13,18 @@ class SequenceStep(BaseModel): class SleepStep(SequenceStep): - sleep: TimeType + sleep: ParsedTimedelta class WaitStateStep(SequenceStep): entity_id: str state: Any - timeout: TimeType = timedelta(minutes=15) + timeout: ParsedTimedelta = timedelta(minutes=15) namespace: str = "default" class LoopStep(SequenceStep): - interval: TimeType + interval: ParsedTimedelta times: int = 1 From 88d565d3351ec1dd757a7270da6278575d5bcadb Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sat, 27 Sep 2025 10:15:56 -0500 Subject: [PATCH 10/14] plugin config cleanup --- appdaemon/admin_loop.py | 2 +- appdaemon/app_management.py | 3 +- appdaemon/models/config/appdaemon.py | 7 ++- appdaemon/models/config/common.py | 2 +- appdaemon/models/config/plugin.py | 68 ++++++++++----------- appdaemon/models/internal/app_management.py | 3 +- appdaemon/plugin_management.py | 7 ++- appdaemon/plugins/hass/hassplugin.py | 13 ++-- appdaemon/plugins/hass/utils.py | 5 +- appdaemon/utility_loop.py | 4 +- docs/HISTORY.md | 6 ++ 11 files changed, 62 insertions(+), 58 deletions(-) diff --git a/appdaemon/admin_loop.py b/appdaemon/admin_loop.py index 6d7c1774f..7de3ba592 100644 --- a/appdaemon/admin_loop.py +++ b/appdaemon/admin_loop.py @@ -31,4 +31,4 @@ async def loop(self): await self.AD.threading.get_callback_update() await self.AD.threading.get_q_update() - await self.AD.utility.sleep(self.AD.admin_delay, timeout_ok=True) + await self.AD.utility.sleep(self.AD.config.admin_delay.total_seconds(), timeout_ok=True) diff --git a/appdaemon/app_management.py b/appdaemon/app_management.py index 3e22f7ff7..f3ee7ef0b 100644 --- a/appdaemon/app_management.py +++ b/appdaemon/app_management.py @@ -590,7 +590,7 @@ def get_managed_app_names(self, include_globals: bool = False, running: bool | N ) # fmt: skip return apps - def add_plugin_object(self, name: str, object: "PluginBase", use_dictionary_unpacking: bool = False) -> None: + def add_plugin_object(self, name: str, object: "PluginBase") -> None: """Add the plugin object to the internal dictionary of ``ManagedObjects``""" self.objects[name] = ManagedObject( type="plugin", @@ -598,7 +598,6 @@ def add_plugin_object(self, name: str, object: "PluginBase", use_dictionary_unpa pin_app=False, pin_thread=-1, running=False, - use_dictionary_unpacking=use_dictionary_unpacking, ) async def terminate_sequence(self, name: str) -> bool: diff --git a/appdaemon/models/config/appdaemon.py b/appdaemon/models/config/appdaemon.py index 475f6f3a3..b8dfa4f6f 100644 --- a/appdaemon/models/config/appdaemon.py +++ b/appdaemon/models/config/appdaemon.py @@ -17,10 +17,15 @@ def plugin_discriminator(plugin: dict[str, Any] | PluginConfig) -> Literal["hass", "mqtt", "custom"]: + """Determine which tag string to use for the plugin config. + + Only built-in plugins like HASS and MQTT use their own config models. Custom plugins will fall back to the generic + PluginConfig model. + """ match plugin: case {"type": str(t)} | PluginConfig(type=str(t)): match t.lower(): - case ("hass" | "mqtt" | "custom") as type_: + case ("hass" | "mqtt") as type_: return type_ return "custom" diff --git a/appdaemon/models/config/common.py b/appdaemon/models/config/common.py index 0c1462401..80c77deb0 100644 --- a/appdaemon/models/config/common.py +++ b/appdaemon/models/config/common.py @@ -13,5 +13,5 @@ CoercedPath = Annotated[Path, BeforeValidator(lambda p: Path(p).resolve())] -CoercedRelPath = Annotated[Path, BeforeValidator(Path)] +CoercedRelPath = Annotated[Path, BeforeValidator(lambda p: Path(p))] LogPath = Annotated[Literal["STDOUT", "STDERR"], BeforeValidator(lambda s: s.upper())] | CoercedPath diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index 87941b7b7..6e348a730 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -1,48 +1,44 @@ -"""This module has the sections""" - import os from datetime import timedelta from ssl import _SSLMethod from typing import Annotated, Any, Literal -from pydantic import BaseModel, BeforeValidator, Field, SecretBytes, SecretStr, ValidationInfo, field_validator, model_validator +from pydantic import BaseModel, BeforeValidator, Field, SecretBytes, SecretStr, field_validator, model_validator from typing_extensions import deprecated -from appdaemon import utils from .common import CoercedPath, ParsedTimedelta class PluginConfig(BaseModel, extra="allow"): - type: str + type: Annotated[str, BeforeValidator(lambda s: s.lower())] name: str - """Gets set by a field_validator in the AppDaemonConfig""" + """Name of the plugin, which is used by the plugin manager to track it. + + This is set by a field_validator in the AppDaemonConfig. + """ disable: bool = False persist_entities: bool = False refresh_delay: ParsedTimedelta = timedelta(minutes=10) """Delay between refreshes of the complete plugin state in the utility loop.""" refresh_timeout: ParsedTimedelta = timedelta(seconds=30) - use_dictionary_unpacking: bool = True - - # Used by the AppDaemon internals to import the plugins. - plugin_module: str = None - plugin_class: str = None - api_module: str = None - api_class: str = None + """Timeout for refreshes of the complete plugin state in the utility loop.""" connect_timeout: ParsedTimedelta = timedelta(seconds=1) - reconnect_delay: ParsedTimedelta = timedelta(seconds=5) + retry_secs: ParsedTimedelta = timedelta(seconds=5) namespace: str = "default" namespaces: list[str] = Field(default_factory=list) + """Additional namespaces to associate with this plugin.""" - @field_validator("type") - @classmethod - def lower(cls, v: str, info: ValidationInfo): - return v.lower() + # Used by the AppDaemon internals to import the plugins. + plugin_module: str = None # pyright: ignore[reportAssignmentType] + plugin_class: str = None # pyright: ignore[reportAssignmentType] + api_module: str = None # pyright: ignore[reportAssignmentType] + api_class: str = None # pyright: ignore[reportAssignmentType] @model_validator(mode="after") - def custom_validator(self): + def set_internal_fields(self): if "plugin_module" not in self.model_fields_set: self.plugin_module = f"appdaemon.plugins.{self.type}.{self.type}plugin" @@ -94,25 +90,21 @@ class HASSConfig(PluginConfig): token: SecretStr ha_key: Annotated[SecretStr, deprecated("'ha_key' is deprecated. Please use long lived tokens instead")] | None = None appdaemon_startup_conditions: StartupConditions | None = None + """Startup conditions that apply only when AppDaemon first starts.""" plugin_startup_conditions: StartupConditions | None = None + """Startup conditions that apply if the plugin is restarted.""" enable_started_event: bool = True - """If true, the plugin will wait for the 'homeassistant_started' event before starting the plugin.""" + """If `True`, the plugin will wait for the 'homeassistant_started' event before starting the plugin. Defaults to + `True`.""" cert_path: CoercedPath | None = None cert_verify: bool = True - commtype: str = "WS" - connect_timeout: float = 1.0 - """Timeout used for the websocket connection to """ - q_timeout: int = 30 - ws_timeout: Annotated[ - timedelta, - BeforeValidator(utils.parse_timedelta) - ] = Field(default_factory=lambda: timedelta(seconds=10)) # fmt: skip + commtype: Annotated[str, deprecated("'commtype' is deprecated")] | None = None + ws_timeout: ParsedTimedelta = timedelta(seconds=10) """Default timeout for waiting for responses from the websocket connection""" suppress_log_messages: bool = False - retry_secs: int = 5 - services_sleep_time: int = 60 + services_sleep_time: ParsedTimedelta = timedelta(seconds=60) """The sleep time in the background task that updates the internal list of available services every once in a while""" - config_sleep_time: int = 60 + config_sleep_time: ParsedTimedelta = timedelta(seconds=60) """The sleep time in the background task that updates the config metadata every once in a while""" @field_validator("ha_key", mode="after") @@ -130,8 +122,9 @@ def validate_ha_url(cls, v: str): @model_validator(mode="after") def custom_validator(self): - self = super().custom_validator() - assert "token" in self.model_fields_set or "ha_key" in self.model_fields_set + assert "token" in self.model_fields_set or "ha_key" in self.model_fields_set, ( + "Either 'token' or 'ha_key' must be set for the Home Assistant plugin" + ) return self @property @@ -151,6 +144,7 @@ def auth_json(self) -> dict: return {"type": "auth", "access_token": self.token.get_secret_value()} elif self.ha_key is not None: return {"type": "auth", "api_password": self.ha_key.get_secret_value()} + raise ValueError("Home Assistant token not set") @property def auth_headers(self) -> dict: @@ -158,6 +152,7 @@ def auth_headers(self) -> dict: return {"Authorization": f"Bearer {self.token.get_secret_value()}"} elif self.ha_key is not None: return {"x-ha-access": self.ha_key} + raise ValueError("Home Assistant token not set") class MQTTConfig(PluginConfig): @@ -175,7 +170,7 @@ class MQTTConfig(PluginConfig): event_name: str = "MQTT_MESSAGE" force_start: bool = False - status_topic: str = None + status_topic: str | None = None birth_topic: str | None = None birth_payload: str = "online" @@ -185,7 +180,7 @@ class MQTTConfig(PluginConfig): will_payload: str = "offline" will_retain: bool = True - shutdown_payload: str = None + shutdown_payload: str | None = None ca_cert: str | None = None client_cert: str | None = None @@ -213,8 +208,7 @@ def validate_client_topics(cls, v: Any) -> list[str]: raise ValueError("client_topics must be a string or a list") @model_validator(mode="after") - def custom_validator(self): - self = super().custom_validator() + def set_topics(self): if "client_id" not in self.model_fields_set: self.client_id = f"appdaemon_{self.name}_client".lower() diff --git a/appdaemon/models/internal/app_management.py b/appdaemon/models/internal/app_management.py index 30c46c508..03087b16e 100644 --- a/appdaemon/models/internal/app_management.py +++ b/appdaemon/models/internal/app_management.py @@ -135,11 +135,10 @@ class ManagedObject: pin_app: bool | None = None pin_thread: int | None = None running: bool = False - use_dictionary_unpacking: bool = False callback_counter: int = 0 lock: threading.RLock = field(init=False, default_factory=threading.RLock) def increment_callback_counter(self, n: int = 1) -> None: - """Increments the callback counter by one""" + """Increments the callback counter""" with self.lock: self.callback_counter += n diff --git a/appdaemon/plugin_management.py b/appdaemon/plugin_management.py index 342c4be45..92db40f01 100644 --- a/appdaemon/plugin_management.py +++ b/appdaemon/plugin_management.py @@ -298,7 +298,7 @@ def __init__(self, ad: "AppDaemon", config: dict[str, PluginConfig]): # Create app entry for the plugin so we can listen_state/event # if self.AD.apps_enabled: - self.AD.app_management.add_plugin_object(name, plugin, self.config[name].use_dictionary_unpacking) + self.AD.app_management.add_plugin_object(name, plugin) self.AD.loop.create_task(plugin.get_updates(), name=f"plugin.get_updates for {name}") except Exception: @@ -484,12 +484,13 @@ async def time_since_plugin_update(self, plugin_name: str) -> datetime.timedelta async def update_plugin_state(self): for plugin, cfg in self.active_plugins: - if await self.time_since_plugin_update(plugin.name) > cfg.refresh_delay: + elapsed = await self.time_since_plugin_update(plugin.name) + if elapsed > cfg.refresh_delay: self.logger.debug(f"Refreshing {plugin.name}[{cfg.type}] state") try: state = await asyncio.wait_for( plugin.get_complete_state(), - timeout=cfg.refresh_timeout, + timeout=cfg.refresh_timeout.total_seconds(), ) except asyncio.TimeoutError: self.logger.warning( diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index 1c8449e32..8e6aeadd4 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -119,7 +119,7 @@ def create_session(self) -> aiohttp.ClientSession: connector=conn, headers=self.config.auth_headers, json_serialize=utils.convert_json, - conn_timeout=self.config.connect_timeout, + conn_timeout=self.config.connect_timeout.total_seconds(), ) async def websocket_msg_factory(self): @@ -199,10 +199,10 @@ async def __post_auth__(self) -> None: case _: raise HAEventsSubError(-1, f"Unknown response from subscribe_events: {res}") - config_coro = looped_coro(self.get_hass_config, self.config.config_sleep_time) + config_coro = looped_coro(self.get_hass_config, self.config.config_sleep_time.total_seconds()) self.AD.loop.create_task(config_coro(self)) - service_coro = looped_coro(self.get_hass_services, self.config.services_sleep_time) + service_coro = looped_coro(self.get_hass_services, self.config.services_sleep_time.total_seconds()) self.AD.loop.create_task(service_coro(self)) if self.first_time: @@ -537,15 +537,12 @@ async def get_updates(self): except Exception as exc: self.error.error(exc) if not self.AD.stopping: - self.logger.info( - "Attempting reconnection in %s seconds", - self.config.retry_secs, - ) + self.logger.info("Attempting reconnection in %s", utils.format_timedelta(self.config.retry_secs)) if self.is_ready: # Will only run the first time through the loop after a failure await self.AD.plugins.notify_plugin_stopped(self.name, self.namespace) self.ready_event.clear() - await self.AD.utility.sleep(self.config.retry_secs, timeout_ok=True) + await self.AD.utility.sleep(self.config.retry_secs.total_seconds(), timeout_ok=True) # always do this block, no matter what finally: diff --git a/appdaemon/plugins/hass/utils.py b/appdaemon/plugins/hass/utils.py index fd0ed58be..44e3ff0f4 100644 --- a/appdaemon/plugins/hass/utils.py +++ b/appdaemon/plugins/hass/utils.py @@ -2,6 +2,8 @@ from enum import Enum, auto from typing import TYPE_CHECKING +from appdaemon import utils + if TYPE_CHECKING: from .hassplugin import HassPlugin @@ -21,7 +23,8 @@ async def loop(self: "HassPlugin", *args, **kwargs): try: await coro() except Exception: - self.logger.error(f"Error running {coro.__name__} - retrying in {sleep_time}s") + sleep_time_str = utils.format_timedelta(sleep_time) + self.logger.error(f"Error running {coro.__name__} - retrying in {sleep_time_str}") finally: await self.AD.utility.sleep(sleep_time, timeout_ok=True) diff --git a/appdaemon/utility_loop.py b/appdaemon/utility_loop.py index 9abe34e89..af253db8b 100644 --- a/appdaemon/utility_loop.py +++ b/appdaemon/utility_loop.py @@ -260,7 +260,7 @@ async def _loop_iteration_context(self) -> AsyncGenerator[LoopTiming]: "Util loop compute time: %s, check_app_updates: %s, other: %s", timing.get_time_strs(), ) - if self.AD.real_time and timing.timedelta("total") > self.AD.max_utility_skew: + if self.AD.real_time and timing.timedelta("total") > self.AD.config.max_utility_skew: self.logger.warning( "Excessive time spent in utility loop: %s, %s in check_app_updates(), %s in other", *timing.get_time_strs(), @@ -270,7 +270,7 @@ async def _loop_iteration_context(self) -> AsyncGenerator[LoopTiming]: self.logger.info(self.AD.app_management.check_app_updates_profile_stats) else: if not self.AD.stopping: - await self.sleep(self.AD.utility_delay, timeout_ok=True) + await self.sleep(self.AD.config.utility_delay.total_seconds(), timeout_ok=True) async def production_mode_service(self, ns, domain, service, kwargs): match kwargs: diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 0f6fd591b..f3ecd8e30 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -16,6 +16,8 @@ - uv version - Docker build/push version - Improved error messages for failed connections to Home Assistant +- Improved error messages for custom plugins +- Parsing various timedeltas in config with `utils.parse_timedelta` **Fixes** @@ -26,6 +28,10 @@ - Type hints for async state callbacks - Various type hints - Reverted discarding of events during app initialize methods to pre-4.5 by default and added an option to turn it on if required (should fix run_in() calls with a delay of 0 during initialize, as well as listen_state() with a duration and immediate=True) +- Fixed logic in presence/person constraints +- Fixed logic in calling services from HA so that things like `input_number/set_value` work with entities in the `number` domain +- Fixed `get_history` for boolean objects +- Fixed config models to allow custom plugins ## 4.5.11 From 3fcbe4f71a9f743cb6985a2b1b1894c431855518 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sat, 27 Sep 2025 10:24:14 -0500 Subject: [PATCH 11/14] app_dir validator --- appdaemon/models/config/appdaemon.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/appdaemon/models/config/appdaemon.py b/appdaemon/models/config/appdaemon.py index b8dfa4f6f..030f1ac67 100644 --- a/appdaemon/models/config/appdaemon.py +++ b/appdaemon/models/config/appdaemon.py @@ -49,7 +49,10 @@ class AppDaemonConfig(BaseModel, extra="allow"): config_dir: CoercedPath config_file: CoercedPath - app_dir: CoercedRelPath + # The CoercedRelPath validator doesn't resolve the relative paths because it will be done relative to wherever + # AppDaemon is started from, which might not be the config directory. + app_dir: CoercedRelPath = Path("./apps") + """Directory to look for apps in, relative to config_dir if not absolute""" write_toml: bool = False ext: Literal[".yaml", ".toml"] = ".yaml" @@ -137,6 +140,12 @@ class AppDaemonConfig(BaseModel, extra="allow"): ) ad_version: str = __version__ + @model_validator(mode="after") + def resolve_app_dir(self) -> "AppDaemonConfig": + if not self.app_dir.is_absolute(): + self.app_dir = (self.config_dir / self.app_dir).resolve() + return self + @field_validator("exclude_dirs", mode="after") @classmethod def add_default_exclusions(cls, v: list[str]) -> list[str]: From c8f4632fc0a63d8867ac9389456089a0df87a5bf Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sat, 27 Sep 2025 11:25:12 -0500 Subject: [PATCH 12/14] added plugin-specific exceptions --- appdaemon/exceptions.py | 49 ++++++++++++++++++++++++++++++++++ appdaemon/plugin_management.py | 37 ++++++++++++++++++------- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/appdaemon/exceptions.py b/appdaemon/exceptions.py index ab32d96e3..86b09ff80 100644 --- a/appdaemon/exceptions.py +++ b/appdaemon/exceptions.py @@ -581,3 +581,52 @@ class NoADConfig(AppDaemonException): def __str__(self): return self.msg + + +@dataclass +class PluginMissingError(AppDaemonException): + plugin_type: str + plugin_name: str + + def __str__(self): + return f"Failed to find plugin '{self.plugin_name}' of type '{self.plugin_type}'" + + +@dataclass +class PluginLoadError(AppDaemonException): + plugin_type: str + plugin_name: str + + def __str__(self): + return f"Failed to load plugin '{self.plugin_name}' of type '{self.plugin_type}'" + + +@dataclass +class PluginTypeError(AppDaemonException): + plugin_type: str + plugin_name: str + + def __str__(self): + return f"Plugin '{self.plugin_name}' of type '{self.plugin_type}' does not extend PluginBase, which is required." + + +@dataclass +class PluginCreateError(AppDaemonException): + plugin_type: str + plugin_name: str + + def __str__(self): + return f"Failed to create plugin '{self.plugin_name}' of type '{self.plugin_type}'" + + +@dataclass +class PluginNamespaceError(AppDaemonException): + plugin_name: str + namespace: str + existing_plugin: str + + def __str__(self): + if self.namespace == "default": + return f"'{self.existing_plugin}' already uses the default namespace, so '{self.plugin_name}' needs to specify a different one." + else: + return f"Namespace '{self.namespace}' is already used by plugin '{self.existing_plugin}'" diff --git a/appdaemon/plugin_management.py b/appdaemon/plugin_management.py index 92db40f01..16fd42912 100644 --- a/appdaemon/plugin_management.py +++ b/appdaemon/plugin_management.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Type +from . import exceptions as ade from . import utils from .app_management import UpdateMode from .models.config import AppConfig @@ -284,23 +285,41 @@ def __init__(self, ad: "AppDaemon", config: dict[str, PluginConfig]): ) try: - module = importlib.import_module(cfg.plugin_module) - plugin_class: Type[PluginBase] = getattr(module, cfg.plugin_class) - plugin: PluginBase = plugin_class(self.AD, name, self.config[name]) - namespace = plugin.config.namespace + try: + module = importlib.import_module(cfg.plugin_module) + except ModuleNotFoundError as e: + raise ade.PluginMissingError(cfg.type, name) from e + except SyntaxError as e: + raise ade.PluginLoadError(cfg.type, name) from e + + try: + plugin_class: Type[PluginBase] = getattr(module, cfg.plugin_class) + except AttributeError as e: + raise ade.PluginMissingError(cfg.type, name) from e + + try: + plugin: PluginBase = plugin_class(self.AD, name, self.config[name]) + if not isinstance(plugin, PluginBase): + raise ade.PluginTypeError(cfg.type, name) + except Exception as e: + raise ade.PluginCreateError(cfg.type, name) from e - if namespace in self.plugin_objs: - raise ValueError(f"Duplicate namespace: {namespace}") + namespace = plugin.config.namespace + match self.plugin_objs.get(namespace): + case None: + pass # This means the namespace is not already taken, which is good + case {"object": PluginBase(name=str(existing_plugin))}: + raise ade.PluginNamespaceError(name, namespace, existing_plugin) self.plugin_objs[namespace] = {"object": plugin, "active": False, "name": name} - # - # Create app entry for the plugin so we can listen_state/event - # if self.AD.apps_enabled: + # Create app entry for the plugin so we can listen_state/event self.AD.app_management.add_plugin_object(name, plugin) self.AD.loop.create_task(plugin.get_updates(), name=f"plugin.get_updates for {name}") + except ade.AppDaemonException as e: + ade.user_exception_block(self.error, e, self.AD.app_dir, f"Plugin failure for '{name}'") except Exception: self.logger.warning("-" * 60) self.logger.warning("error loading plugin: %s - ignoring", name) From 3b0de47a846932787a6a712a5816a2653c6ec6f6 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sat, 27 Sep 2025 11:28:30 -0500 Subject: [PATCH 13/14] startup text --- appdaemon/plugin_management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appdaemon/plugin_management.py b/appdaemon/plugin_management.py index 16fd42912..4dc96c8f0 100644 --- a/appdaemon/plugin_management.py +++ b/appdaemon/plugin_management.py @@ -274,9 +274,9 @@ def __init__(self, ad: "AppDaemon", config: dict[str, PluginConfig]): self.logger.info("Plugin '%s' disabled", name) else: if name.lower() in built_ins: - msg = "Loading Plugin %s using class %s from module %s" + msg = "Loading built-in plugin '%s' using '%s' from '%s'" else: - msg = "Loading Custom Plugin %s using class %s from module %s" + msg = "Loading custom plugin '%s' using '%s' from '%s'" self.logger.info( msg, name, From 99dd073ec5bcc2a34369e263015214d87d9ca07b Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sat, 27 Sep 2025 11:44:51 -0500 Subject: [PATCH 14/14] generator for ready_events --- appdaemon/plugin_management.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/appdaemon/plugin_management.py b/appdaemon/plugin_management.py index 4dc96c8f0..d83a66bcd 100644 --- a/appdaemon/plugin_management.py +++ b/appdaemon/plugin_management.py @@ -451,6 +451,14 @@ async def notify_plugin_stopped(self, name: str, namespace: str): def get_plugin_meta(self, namespace: str) -> dict: return self.plugin_meta.get(namespace, {}) + + def _ready_events(self) -> Generator[tuple[str, asyncio.Event]]: + for plugin_cfg in self.plugin_objs.values(): + match plugin_cfg: + case {"object": PluginBase(name=str(name), ready_event=asyncio.Event() as event)}: + yield name, event + + async def wait_for_plugins(self, timeout: float | None = None): """Waits for the user-configured plugin startup conditions. @@ -458,11 +466,8 @@ async def wait_for_plugins(self, timeout: float | None = None): """ self.logger.info("Waiting for plugins to be ready") wait_tasks = [ - self.AD.loop.create_task( - plugin["object"].ready_event.wait(), - name=f"waiting for {plugin['name']} to be ready", - ) - for plugin in self.plugin_objs.values() + self.AD.loop.create_task(event.wait(), name=f"waiting for {plugin_name} to be ready") + for plugin_name, event in self._ready_events() ] readiness = self.AD.loop.create_task( asyncio.wait(wait_tasks, timeout=timeout, return_when=asyncio.ALL_COMPLETED),