Skip to content
2 changes: 1 addition & 1 deletion appdaemon/admin_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 1 addition & 2 deletions appdaemon/app_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,15 +590,14 @@ 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",
object=object,
pin_app=False,
pin_thread=-1,
running=False,
use_dictionary_unpacking=use_dictionary_unpacking,
)

async def terminate_sequence(self, name: str) -> bool:
Expand Down
11 changes: 0 additions & 11 deletions appdaemon/appdaemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions appdaemon/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'"
99 changes: 55 additions & 44 deletions appdaemon/models/config/appdaemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 9 additions & 41 deletions appdaemon/models/config/common.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion appdaemon/models/config/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 4 additions & 4 deletions appdaemon/models/config/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -14,16 +14,16 @@


class AppDaemonLogConfig(BaseModel):
filename: CoercedPath = "STDOUT"
filename: LogPath = "STDOUT"
name: str | None = None
level: LogLevel = 'INFO'
log_generations: int = 3
log_size: int = 1000000
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):
Expand Down
Loading
Loading