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/appdaemon.py b/appdaemon/appdaemon.py index 140afe467..579d895d6 100755 --- a/appdaemon/appdaemon.py +++ b/appdaemon/appdaemon.py @@ -166,9 +166,6 @@ def __init__( # # Property definitions # - @property - def admin_delay(self) -> int: - return self.config.admin_delay @property def api_port(self) -> int | None: @@ -260,14 +257,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 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/models/config/appdaemon.py b/appdaemon/models/config/appdaemon.py index ffb9e9789..030f1ac67 100644 --- a/appdaemon/models/config/appdaemon.py +++ b/appdaemon/models/config/appdaemon.py @@ -5,22 +5,35 @@ 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.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, CoercedRelPath, ParsedTimedelta from .misc import FilterConfig, NamespaceConfig +from .plugin import HASSConfig, MQTTConfig, PluginConfig -def plugin_discriminator(plugin): - if isinstance(plugin, dict): - return plugin["type"].lower() - else: - plugin.type + +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") as type_: + return type_ + return "custom" + + +DiscriminatedPluginConfig = Annotated[ + Annotated[HASSConfig, Tag("hass")] | Annotated[MQTTConfig, Tag("mqtt")] | Annotated[PluginConfig, Tag("custom")], + Discriminator(plugin_discriminator), +] class ModuleLoggingLevels(RootModel): @@ -31,18 +44,15 @@ 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")], - 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 + # 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" @@ -52,7 +62,6 @@ 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=ModuleLoggingLevels) @@ -63,14 +72,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: 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: Annotated[ - timedelta, - BeforeValidator(utils.parse_timedelta) - ] = Field(default_factory=lambda: 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 @@ -79,10 +85,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: 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 @@ -91,6 +94,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 @@ -131,33 +136,39 @@ 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): - return v.resolve() + @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[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 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/common.py b/appdaemon/models/config/common.py index a8b0bc875..80c77deb0 100644 --- a/appdaemon/models/config/common.py +++ b/appdaemon/models/config/common.py @@ -1,49 +1,17 @@ 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 pydantic import BeforeValidator, PlainSerializer -LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - - -def coerce_path(v: Any) -> Path | Literal["STDOUT", "STDERR"]: - """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 _: - 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}") +from appdaemon.utils import parse_timedelta +LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] -TimeType = Annotated[timedelta, BeforeValidator(validate_timedelta), PlainSerializer(lambda td: td.total_seconds())] +BoolNum = Annotated[bool, BeforeValidator(lambda v: False if int(v) == 0 else True)] +ParsedTimedelta = Annotated[timedelta, BeforeValidator(parse_timedelta), PlainSerializer(lambda td: td.total_seconds())] -BoolNum = Annotated[bool, BeforeValidator(lambda v: False if int(v) == 0 else True)] +CoercedPath = Annotated[Path, BeforeValidator(lambda p: Path(p).resolve())] +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/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 2b60b1900..29b5a2f4a 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, ParsedTimedelta 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 @@ -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 f87263cb2..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 .common import CoercedPath -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: Annotated[timedelta, BeforeValidator(lambda v: timedelta(minutes=v))] = timedelta(minutes=10) - """Delay between refreshes of the complete plugin state in the utility loop""" - refresh_timeout: int = 30 - use_dictionary_unpacking: bool = True + refresh_delay: ParsedTimedelta = timedelta(minutes=10) + """Delay between refreshes of the complete plugin state in the utility loop.""" + refresh_timeout: ParsedTimedelta = timedelta(seconds=30) + """Timeout for refreshes of the complete plugin state in the utility loop.""" - # 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 - - connect_timeout: int | float = 1.0 - reconnect_delay: int | float = 5.0 + connect_timeout: ParsedTimedelta = timedelta(seconds=1) + 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" @@ -61,6 +57,12 @@ def custom_validator(self): 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): state: Any @@ -88,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") @@ -124,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 @@ -145,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: @@ -152,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): @@ -169,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" @@ -179,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 @@ -207,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/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 diff --git a/appdaemon/models/config/yaml.py b/appdaemon/models/config/yaml.py index a4bf31dbb..a9c34f7a2 100644 --- a/appdaemon/models/config/yaml.py +++ b/appdaemon/models/config/yaml.py @@ -4,7 +4,8 @@ from pydantic import BaseModel, Field, 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,15 @@ 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) + case _: + raise ValueError(f"Invalid configuration file: {file}") @classmethod def from_cli_kwargs(cls, cli_kwargs: AppDaemonCLIKwargs): 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 0fbedbdc6..d83a66bcd 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 @@ -273,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, @@ -284,24 +285,43 @@ 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: - self.AD.app_management.add_plugin_object(name, plugin, self.config[name].use_dictionary_unpacking) + # 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) self.logger.warning("-" * 60) self.logger.warning(traceback.format_exc()) @@ -431,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. @@ -438,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), @@ -469,9 +494,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""" @@ -482,12 +508,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