diff --git a/appdaemon/adapi.py b/appdaemon/adapi.py index 9c9dd5599..d115e9458 100644 --- a/appdaemon/adapi.py +++ b/appdaemon/adapi.py @@ -17,10 +17,10 @@ from appdaemon import dependency, utils from appdaemon import exceptions as ade from appdaemon.appdaemon import AppDaemon -from appdaemon.models.config.app import AppConfig from appdaemon.entity import Entity from appdaemon.events import EventCallback from appdaemon.logging import Logging +from appdaemon.models.config.app import AppConfig from appdaemon.state import StateCallback T = TypeVar("T") @@ -53,9 +53,6 @@ class ADAPI: AD: AppDaemon """Reference to the top-level AppDaemon container object """ - config_model: "AppConfig" - """Pydantic model of the app configuration - """ config: dict[str, Any] """Dict of the AppDaemon configuration. This meant to be read-only, and modifying it won't affect any behavior. """ @@ -116,7 +113,7 @@ def _get_namespace(self, **kwargs): namespace = kwargs["namespace"] del kwargs["namespace"] else: - namespace = self._namespace + namespace = self.namespace return namespace @@ -132,7 +129,7 @@ def app_dir(self) -> Path: @app_dir.setter def app_dir(self, value: Path) -> None: - self.logger.warning('app_dir is read-only and needs to be set before AppDaemon starts') + self.logger.warning("app_dir is read-only and needs to be set before AppDaemon starts") @property def callback_counter(self) -> int: @@ -140,7 +137,7 @@ def callback_counter(self) -> int: @callback_counter.setter def callback_counter(self, value: Path) -> None: - self.logger.warning('callback_counter is read-only and is set internally by AppDaemon') + self.logger.warning("callback_counter is read-only and is set internally by AppDaemon") @property def config_dir(self) -> Path: @@ -149,7 +146,7 @@ def config_dir(self) -> Path: @config_dir.setter def config_dir(self, value: Path) -> None: - self.logger.warning('config_dir is read-only and needs to be set before AppDaemon starts') + self.logger.warning("config_dir is read-only and needs to be set before AppDaemon starts") @property def config_model(self) -> AppConfig: @@ -183,7 +180,7 @@ def _logging(self) -> Logging: @_logging.setter def _logging(self, value: Logging) -> None: - self.logger.warning('The _logging property is read-only') + self.logger.warning("The _logging property is read-only") @property def name(self) -> str: @@ -208,7 +205,16 @@ def plugin_config(self, value: dict) -> None: # def _log( - self, logger: Logger, msg: str, level: str | int = "INFO", *args, ascii_encode: bool = True, stack_info: bool = False, stacklevel: int = 1, extra: Mapping[str, object] | None = None, **kwargs + self, + logger: Logger, + msg: str, + level: str | int = "INFO", + *args, + ascii_encode: bool = True, + stack_info: bool = False, + stacklevel: int = 1, + extra: Mapping[str, object] | None = None, + **kwargs, ) -> None: if ascii_encode: msg = str(msg).encode("utf-8", "replace").decode("ascii", "replace") @@ -291,7 +297,17 @@ def log( self._log(logger, msg, level, *args, **kwargs) - def error(self, msg: str, *args, level: str | int = "INFO", ascii_encode: bool = True, stack_info: bool = False, stacklevel: int = 1, extra: Mapping[str, object] | None = None, **kwargs) -> None: + def error( + self, + msg: str, + *args, + level: str | int = "INFO", + ascii_encode: bool = True, + stack_info: bool = False, + stacklevel: int = 1, + extra: Mapping[str, object] | None = None, + **kwargs, + ) -> None: """Logs a message to AppDaemon's error logfile. Args: @@ -316,11 +332,28 @@ def error(self, msg: str, *args, level: str | int = "INFO", ascii_encode: bool = >>> self.error("Some Critical string", level = "CRITICAL") """ - self._log(self.err, msg, level, *args, ascii_encode=ascii_encode or self.AD.config.ascii_encode, stack_info=stack_info, stacklevel=stacklevel, extra=extra, **kwargs) + self._log( + self.err, + msg, + level, + *args, + ascii_encode=ascii_encode or self.AD.config.ascii_encode, + stack_info=stack_info, + stacklevel=stacklevel, + extra=extra, + **kwargs, + ) @utils.sync_decorator async def listen_log( - self, callback: Callable, level: str | int = "INFO", namespace: str = "admin", log: str | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs + self, + callback: Callable, + level: str | int = "INFO", + namespace: str = "admin", + log: str | None = None, + pin: bool | None = None, + pin_thread: int | None = None, + **kwargs, ) -> list[str] | None: """Register a callback for whenever an app logs a message. @@ -360,7 +393,16 @@ async def listen_log( >>> self.handle = self.listen_log(self.cb, "WARNING", log="my_custom_log") """ - return await self.AD.logging.add_log_callback(namespace=namespace, name=self.name, callback=callback, level=level, log=log, pin=pin, pin_thread=pin_thread, **kwargs) + return await self.AD.logging.add_log_callback( + namespace=namespace, + name=self.name, + callback=callback, + level=level, + log=log, + pin=pin, + pin_thread=pin_thread, + **kwargs, + ) @utils.sync_decorator async def cancel_listen_log(self, handle: str) -> None: @@ -623,8 +665,13 @@ async def add_namespace(self, namespace: str, writeback: str = "safe", persist: """ new_namespace = await self.AD.state.add_namespace(namespace, writeback, persist, self.name) - self.AD.state.app_added_namespaces.add(new_namespace) - return new_namespace + match new_namespace: + case Path() | str(): + new_namespace = str(new_namespace) + self.AD.state.app_added_namespaces.add(new_namespace) + return new_namespace + case _: + self.logger.warning("Namespace %s already exists or was not created", namespace) @utils.sync_decorator async def remove_namespace(self, namespace: str) -> dict[str, Any] | None: @@ -735,7 +782,13 @@ def get_ad_version() -> str: # @utils.sync_decorator - async def add_entity(self, entity_id: str, state: Any | None = None, attributes: dict | None = None, namespace: str | None = None) -> None: + async def add_entity( + self, + entity_id: str, + state: str | dict[str, Any], + attributes: dict | None = None, + namespace: str | None = None, + ) -> None: """Adds a non-existent entity, by creating it within a namespaces. If an entity doesn't exists and needs to be created, this function can be used to create it locally. @@ -762,11 +815,7 @@ async def add_entity(self, entity_id: str, state: Any | None = None, attributes: >>> self.add_entity('mqtt.living_room_temperature', namespace='mqtt') """ - - if self.entity_exists(entity_id, namespace): - self.logger.warning("%s already exists, will not be adding it", entity_id) - return None - + namespace = namespace or self.namespace return await self.AD.state.add_entity(namespace, entity_id, state, attributes) @utils.sync_decorator @@ -905,7 +954,7 @@ async def get_plugin_config(self, namespace: str | None = None) -> Any: return self.AD.plugins.get_plugin_meta(namespace) @utils.sync_decorator - async def friendly_name(self, entity_id: str, namespace: str | None = None) -> str: + async def friendly_name(self, entity_id: str, namespace: str | None = None) -> str | None: """Gets the Friendly Name of an entity. Args: @@ -927,14 +976,13 @@ async def friendly_name(self, entity_id: str, namespace: str | None = None) -> s """ namespace = namespace or self.namespace self._check_entity(namespace, entity_id) - - return await self.get_state(entity_id=entity_id, attribute="friendly_name", default=entity_id, namespace=namespace, copy=False) - # if entity_id in state: - # if "friendly_name" in state[entity_id]["attributes"]: - # return state[entity_id]["attributes"]["friendly_name"] - # else: - # return entity_id - # return None + return await self.get_state( + entity_id=entity_id, + attribute="friendly_name", + default=entity_id, + namespace=namespace, + copy=False, + ) # fmt: skip @utils.sync_decorator async def set_production_mode(self, mode: bool = True) -> bool | None: @@ -1216,7 +1264,12 @@ def get_alexa_slot_value(data, slot=None) -> str | None: # @utils.sync_decorator - async def register_endpoint(self, callback: Callable[[Any, dict], Any], endpoint: str | None = None, **kwargs) -> str | None: + async def register_endpoint( + self, + callback: Callable[[Any, dict], Any], + endpoint: str | None = None, + **kwargs, + ) -> str | None: # fmt: skip """Registers an endpoint for API calls into the current App. Args: @@ -1279,7 +1332,12 @@ async def deregister_endpoint(self, handle: str) -> None: # @utils.sync_decorator - async def register_route(self, callback: Callable[[Any, dict], Any], route: str | None = None, **kwargs: dict[str, Any]) -> str | None: + async def register_route( + self, + callback: Callable[[Any, dict], Any], + route: str | None = None, + **kwargs: dict[str, Any], + ) -> str | None: # fmt: skip """Registers a route for Web requests into the current App. By registering an app web route, this allows to make use of AD's internal web server to serve web clients. All routes registered using this api call, can be accessed using @@ -1528,8 +1586,17 @@ async def listen_state( # pre-fill some arguments here add_callback = functools.partial( - self.AD.state.add_state_callback, name=self.name, namespace=namespace, cb=callback, timeout=timeout, oneshot=oneshot, immediate=immediate, pin=pin, pin_thread=pin_thread, kwargs=kwargs - ) + self.AD.state.add_state_callback, + name=self.name, + namespace=namespace, + cb=callback, + timeout=timeout, + oneshot=oneshot, + immediate=immediate, + pin=pin, + pin_thread=pin_thread, + kwargs=kwargs, + ) # fmt: skip match entity_id: case str() | None: @@ -1597,7 +1664,7 @@ async def get_state( namespace: str | None = None, copy: bool = True, **kwargs, # left in intentionally for compatibility - ) -> Any | dict[str, Any] | None: + ) -> Any | dict[str, Any] | None: # fmt: skip """Get the state of an entity from AppDaemon's internals. Home Assistant emits a ``state_changed`` event for every state change, which it sends to AppDaemon over the @@ -1655,12 +1722,26 @@ async def get_state( if kwargs: self.logger.warning(f"Extra kwargs passed to get_state, will be ignored: {kwargs}") - return await self.AD.state.get_state(name=self.name, namespace=namespace or self.namespace, entity_id=entity_id, attribute=attribute, default=default, copy=copy) + return await self.AD.state.get_state( + name=self.name, + namespace=namespace or self.namespace, + entity_id=entity_id, + attribute=attribute, + default=default, + copy=copy, + ) @utils.sync_decorator async def set_state( - self, entity_id: str, state: Any | None = None, namespace: str | None = None, attributes: dict[str, Any] | None = None, replace: bool = False, check_existence: bool = True, **kwargs: Any - ) -> dict[str, Any]: + self, + entity_id: str, + state: Any | None = None, + namespace: str | None = None, + attributes: dict[str, Any] | None = None, + replace: bool = False, + check_existence: bool = True, + **kwargs: Any, + ) -> dict[str, Any] | None: """Update the state of the specified entity. This causes a ``state_changed`` event to be emitted in the entity's namespace. If that namespace is associated @@ -1703,7 +1784,15 @@ async def set_state( namespace = namespace or self.namespace if check_existence: self._check_entity(namespace, entity_id) - return await self.AD.state.set_state(name=self.name, namespace=namespace, entity=entity_id, state=state, attributes=attributes, replace=replace, **kwargs) + return await self.AD.state.set_state( + name=self.name, + namespace=namespace, + entity=entity_id, + state=state, + attributes=attributes, + replace=replace, + **kwargs, + ) # # Services @@ -1815,7 +1904,7 @@ async def call_service( self, service: str, namespace: str | None = None, - timeout: str | int | float | None = None, # Used by utils.sync_decorator + timeout: str | int | float | None = -1, # Used by utils.sync_decorator callback: Callable[[Any], Any] | None = None, **data: Any, ) -> Any: @@ -1903,6 +1992,9 @@ async def call_service( for e in eid: self._check_entity(namespace, e) + if timeout != -1: + data["timeout"] = timeout + domain, service_name = service.split("/", 2) coro = self.AD.services.call_service(namespace=namespace, domain=domain, service=service_name, data=data) if callback is None: @@ -2015,7 +2107,7 @@ async def listen_event( self, callback: EventCallback, event: str | Iterable[str] | None = None, - *, + *, # Arguments after this are keyword only namespace: str | Literal["global"] | None = None, timeout: str | int | float | timedelta | None = None, oneshot: bool = False, @@ -2025,7 +2117,6 @@ async def listen_event( ) -> str | list[str]: """Register a callback for a specific event, multiple events, or any event. - The callback needs to have the following form: >>> def my_callback(self, event_name: str, event_data: dict[str, Any], **kwargs: Any) -> None: ... @@ -2093,8 +2184,16 @@ async def listen_event( # pre-fill some arguments here add_callback = functools.partial( - self.AD.events.add_event_callback, name=self.name, namespace=namespace or self.namespace, cb=callback, timeout=timeout, oneshot=oneshot, pin=pin, pin_thread=pin_thread, kwargs=kwargs - ) + self.AD.events.add_event_callback, + name=self.name, + namespace=namespace or self.namespace, + cb=callback, + timeout=timeout, + oneshot=oneshot, + pin=pin, + pin_thread=pin_thread, + kwargs=kwargs, + ) # fmt: skip match event: case str() | None: @@ -2168,7 +2267,13 @@ async def info_listen_event(self, handle: str) -> bool: return await self.AD.events.info_event_callback(self.name, handle) @utils.sync_decorator - async def fire_event(self, event: str, namespace: str | None = None, **kwargs) -> None: + async def fire_event( + self, + event: str, + namespace: str | None = None, + timeout: str | int | float | timedelta | None = -1, # Used by utils.sync_decorator + **kwargs, + ) -> None: """Fires an event on the AppDaemon bus, for apps and plugins. Args: @@ -2186,6 +2291,11 @@ async def fire_event(self, event: str, namespace: str | None = None, **kwargs) - >>> self.fire_event("MY_CUSTOM_EVENT", jam="true") """ + # The event might need the timeout argument passed through + if timeout != -1: # Only pass through valid values, which includes None + # Convert to float if it's not None + timeout = utils.parse_timedelta(timeout).total_seconds() if timeout is not None else timeout + kwargs["timeout"] = timeout namespace = namespace or self.namespace await self.AD.events.fire_event(namespace, event, **kwargs) @@ -2206,8 +2316,10 @@ def parse_utc_string(self, utc_string: str) -> float: nums = list( map( int, - re.split(r"[^\d]", utc_string)[:-1], # split by anything that's not a number and skip the last part for AM/PM - ) + re.split(r"[^\d]", utc_string)[ + :-1 + ], # split by anything that's not a number and skip the last part for AM/PM + ), )[:7] # Use a max of 7 parts return dt.datetime(*nums).timestamp() + self.get_tz_offset() * 60 @@ -2261,7 +2373,14 @@ async def sun_down(self) -> bool: return await self.AD.sched.sun_down() @utils.sync_decorator - async def parse_time(self, time_str: str, name: str | None = None, aware: bool = False, today: bool = False, days_offset: int = 0) -> dt.time: + async def parse_time( + self, + time_str: str, + name: str | None = None, + aware: bool = False, + today: bool = False, + days_offset: int = 0, + ) -> dt.time: """Creates a `time` object from its string representation. This functions takes a string representation of a time, or sunrise, @@ -2308,10 +2427,23 @@ async def parse_time(self, time_str: str, name: str | None = None, aware: bool = 05:33:17 """ - return await self.AD.sched.parse_time(time_str=time_str, name=name or self.name, aware=aware, today=today, days_offset=days_offset) + return await self.AD.sched.parse_time( + time_str=time_str, + name=name or self.name, + aware=aware, + today=today, + days_offset=days_offset, + ) @utils.sync_decorator - async def parse_datetime(self, time_str: str, name: str | None = None, aware: bool = False, today: bool = False, days_offset: int = 0) -> dt.datetime: + async def parse_datetime( + self, + time_str: str, + name: str | None = None, + aware: bool = False, + today: bool = False, + days_offset: int = 0, + ) -> dt.datetime: """Creates a `datetime` object from its string representation. This function takes a string representation of a date and time, or sunrise, @@ -2360,7 +2492,13 @@ async def parse_datetime(self, time_str: str, name: str | None = None, aware: bo >>> self.parse_datetime("sunrise + 01:00:00") 2019-08-16 06:33:17 """ - return await self.AD.sched.parse_datetime(time_str=time_str, name=name or self.name, aware=aware, today=today, days_offset=days_offset) + return await self.AD.sched.parse_datetime( + time_str=time_str, + name=name or self.name, + aware=aware, + today=today, + days_offset=days_offset, + ) @utils.sync_decorator async def get_now(self, aware: bool = True) -> dt.datetime: @@ -2398,7 +2536,13 @@ async def now_is_between(self, start_time: str, end_time: str, name: str) -> boo async def now_is_between(self, start_time: str, end_time: str, now: str) -> bool: ... @utils.sync_decorator - async def now_is_between(self, start_time: str | dt.datetime, end_time: str | dt.datetime, name: str | None = None, now: str | None = None) -> bool: + async def now_is_between( + self, + start_time: str | dt.datetime, + end_time: str | dt.datetime, + name: str | None = None, + now: str | None = None, + ) -> bool: """Determine if the current `time` is within the specified start and end times. This function takes two string representations of a ``time`` ()or ``sunrise`` or ``sunset`` offset) and returns @@ -2739,7 +2883,16 @@ async def run_once( >>> handle = self.run_once(self.delayed_callback, "sunrise + 01:00:00") """ - return await self.run_at(callback, start, *args, random_start=random_start, random_end=random_end, pin=pin, pin_thread=pin_thread, **kwargs) + return await self.run_at( + callback, + start, + *args, + random_start=random_start, + random_end=random_end, + pin=pin, + pin_thread=pin_thread, + **kwargs, + ) @utils.sync_decorator async def run_at( @@ -2961,7 +3114,17 @@ async def run_hourly( >>> self.run_hourly(self.run_hourly_c, runtime) """ - return await self.run_every(callback, start, timedelta(hours=1), *args, random_start=random_start, random_end=random_end, pin=pin, pin_thread=pin_thread, **kwargs) + return await self.run_every( + callback, + start, + timedelta(hours=1), + *args, + random_start=random_start, + random_end=random_end, + pin=pin, + pin_thread=pin_thread, + **kwargs, + ) @utils.sync_decorator async def run_minutely( @@ -3013,7 +3176,17 @@ async def run_minutely( >>> self.run_minutely(self.run_minutely_c, time) """ - return await self.run_every(callback, start, timedelta(minutes=1), *args, random_start=random_start, random_end=random_end, pin=pin, pin_thread=pin_thread, **kwargs) + return await self.run_every( + callback, + start, + timedelta(minutes=1), + *args, + random_start=random_start, + random_end=random_end, + pin=pin, + pin_thread=pin_thread, + **kwargs, + ) @utils.sync_decorator async def run_every( @@ -3294,7 +3467,15 @@ async def run_at_sunrise( # Dashboard # - def dash_navigate(self, target: str, timeout: int = -1, ret: str | None = None, sticky: int = 0, deviceid: str | None = None, dashid: str | None = None) -> None: + def dash_navigate( + self, + target: str, + timeout: str | int | float | timedelta | None = -1, # Used by utils.sync_decorator + ret: str | None = None, + sticky: int = 0, + deviceid: str | None = None, + dashid: str | None = None, + ) -> None: """Forces all connected Dashboards to navigate to a new URL. Args: @@ -3331,15 +3512,13 @@ def dash_navigate(self, target: str, timeout: int = -1, ret: str | None = None, """ kwargs = {"command": "navigate", "target": target, "sticky": sticky} - if timeout != -1: - kwargs["timeout"] = timeout if ret is not None: kwargs["return"] = ret if deviceid is not None: kwargs["deviceid"] = deviceid if dashid is not None: kwargs["dashid"] = dashid - self.fire_event("ad_dashboard", **kwargs) + self.fire_event("ad_dashboard", timeout=timeout, **kwargs) # # Async @@ -3369,7 +3548,13 @@ async def run_in_executor(self, func: Callable[..., T], *args, **kwargs) -> T: future = self.AD.loop.run_in_executor(self.AD.executor, preloaded_function) return await future - def submit_to_executor(self, func: Callable[..., T], *args, callback: Callable | None = None, **kwargs) -> Future[T]: + def submit_to_executor( + self, + func: Callable[..., T], + *args, + callback: Callable | None = None, + **kwargs, + ) -> Future[T]: """Submit a sync function from within another sync function to be executed using a thread from AppDaemon's internal thread pool. @@ -3438,7 +3623,13 @@ def callback_inner(f: Future): return future @utils.sync_decorator - async def create_task(self, coro: Coroutine[Any, Any, T], callback: Callable | None = None, name: str | None = None, **kwargs) -> asyncio.Task[T]: + async def create_task( + self, + coro: Coroutine[Any, Any, T], + callback: Callable | None = None, + name: str | None = None, + **kwargs, + ) -> asyncio.Task[T]: """Wrap the `coro` coroutine into a ``Task`` and schedule its execution. Return the ``Task`` object. Uses AppDaemon's internal event loop to run the task, so the task will be run in the same thread as the app. diff --git a/appdaemon/adbase.py b/appdaemon/adbase.py index 8e8d239f7..38ddf30b1 100644 --- a/appdaemon/adbase.py +++ b/appdaemon/adbase.py @@ -4,10 +4,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from appdaemon import adapi -from appdaemon import utils -from appdaemon.models.config.app import AppConfig, AllAppConfig - +from appdaemon import adapi, utils +from appdaemon.models.config.app import AllAppConfig, AppConfig # Check if the module is being imported using the legacy method if __name__ == Path(__file__).name: @@ -79,7 +77,6 @@ class ADBase: args: dict """Dictionary of the app configuration """ - _namespace: str logger: Logger diff --git a/appdaemon/admin.py b/appdaemon/admin.py index a654d25ca..e05e3ecd5 100644 --- a/appdaemon/admin.py +++ b/appdaemon/admin.py @@ -11,6 +11,8 @@ class Admin: + name: str = "_admin" + def __init__(self, config_dir, logger, ad: "AppDaemon", **kwargs): # # Set Defaults diff --git a/appdaemon/appdaemon.py b/appdaemon/appdaemon.py index 602d551cb..3e55fed68 100755 --- a/appdaemon/appdaemon.py +++ b/appdaemon/appdaemon.py @@ -4,8 +4,7 @@ from concurrent.futures import ThreadPoolExecutor from pathlib import Path from threading import RLock -from typing import TYPE_CHECKING, Optional - +from typing import TYPE_CHECKING, Any from appdaemon.admin_loop import AdminLoop from appdaemon.app_management import AppManagement @@ -30,7 +29,8 @@ class AppDaemon(metaclass=Singleton): - """Top-level container for the subsystem objects. This gets passed to the subsystem objects and stored in them as the ``self.AD`` attribute. + """Top-level container for the subsystem objects. This gets passed to the subsystem objects and stored in them as + the ``self.AD`` attribute. Asyncio: @@ -81,36 +81,22 @@ class AppDaemon(metaclass=Singleton): """ # subsystems - app_management: AppManagement - callbacks: Callbacks = None - events: Events = None - futures: Futures + app_management: "AppManagement" + callbacks: "Callbacks" + events: "Events" + futures: "Futures" logging: "Logging" - plugins: PluginManagement - scheduler: Scheduler - services: Services - sequences: Sequences - state: State - threading: Threading - thread_async: ThreadAsync = None - utility: Utility - - # settings - app_dir: Path - """Defined in the main YAML config under ``appdaemon.app_dir``. Defaults to ``./apps`` - """ - config_dir: Path - """Path to the AppDaemon configuration files. Defaults to the first folder that has ``./apps`` - - - ``~/.homeassistant`` - - ``/etc/appdaemon`` - """ - apps: bool - """Flag for whether ``disable_apps`` was set in the AppDaemon config - """ - - admin_loop: AdminLoop | None = None - http: Optional["HTTP"] = None + plugins: "PluginManagement" + scheduler: "Scheduler" + services: "Services" + sequences: "Sequences" + state: "State" + threading: "Threading" + thread_async: "ThreadAsync | None" = None + utility: "Utility" + + admin_loop: "AdminLoop | None" = None + http: "HTTP | None" = None global_lock: RLock = RLock() # shut down flag @@ -122,13 +108,13 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App self.config = ad_config_model self.booted = "booting" self.logger = logging.get_logger() - self.logging.register_ad(self) # needs to go last to reference the config object + self.logging.register_ad(self) # needs to go last to reference the config object - self.global_vars = {} + self.global_vars: Any = {} self.main_thread_id = threading.current_thread().ident if not self.apps: - self.logging.log("INFO", "Apps are disabled") + self.logger.info("Apps are disabled") # Initialize subsystems self.callbacks = Callbacks(self) @@ -143,7 +129,8 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App assert self.config_dir is not None, "Config_dir not set. This is a development problem" assert self.config_dir.exists(), f"{self.config_dir} does not exist" assert os.access( - self.config_dir, os.R_OK | os.X_OK + self.config_dir, + os.R_OK | os.X_OK, ), f"{self.config_dir} does not have the right permissions" # this will always be None because it never gets set in ad_kwargs in __main__.py @@ -152,7 +139,8 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App if not self.app_dir.exists(): self.app_dir.mkdir() assert os.access( - self.app_dir, os.R_OK | os.W_OK | os.X_OK + self.app_dir, + os.R_OK | os.W_OK | os.X_OK, ), f"{self.app_dir} does not have the right permissions" self.logger.info(f"Using {self.app_dir} as app_dir") @@ -179,19 +167,25 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App # Property definitions # @property - def admin_delay(self): + def admin_delay(self) -> int: return self.config.admin_delay @property - def api_port(self): + def api_port(self) -> int | None: return self.config.api_port @property - def app_dir(self): + def app_dir(self) -> Path: + """Defined in the main YAML config under ``appdaemon.app_dir``. Defaults to ``./apps``""" return self.config.app_dir + @app_dir.setter + def app_dir(self, path: os.PathLike) -> None: + self.config.app_dir = Path(path) + @property def apps(self): + """Flag for whether ``disable_apps`` was set in the AppDaemon config""" return not self.config.disable_apps @property @@ -204,8 +198,17 @@ def check_app_updates_profile(self): @property def config_dir(self): + """Path to the AppDaemon configuration files. Defaults to the first folder that has ``./apps`` + + - ``~/.homeassistant`` + - ``/etc/appdaemon`` + """ return self.config.config_dir + @config_dir.setter + def config_dir(self, path: os.PathLike) -> None: + self.config.config_dir = Path(path) + @property def config_file(self): return self.config.config_file @@ -366,7 +369,7 @@ def terminate(self): def register_http(self, http: "HTTP"): """Sets the ``self.http`` attribute with a :class:`~.http.HTTP` object and starts the admin loop.""" - self.http: "HTTP" = http + self.http = http # Create admin loop if http.old_admin is not None or http.admin is not None: diff --git a/appdaemon/events.py b/appdaemon/events.py index d7e8f84be..675604f6e 100644 --- a/appdaemon/events.py +++ b/appdaemon/events.py @@ -1,15 +1,16 @@ -from collections.abc import Iterable import datetime import json import traceback import uuid +from collections.abc import Callable, Iterable from copy import deepcopy from logging import Logger from typing import TYPE_CHECKING, Any, Protocol -from collections.abc import Callable import appdaemon.utils as utils +from .plugin_management import PluginBase + if TYPE_CHECKING: from appdaemon.appdaemon import AppDaemon @@ -190,13 +191,14 @@ async def fire_event(self, namespace: str, event: str, **kwargs): self.logger.debug("fire_plugin_event() %s %s %s", namespace, event, kwargs) plugin = self.AD.plugins.get_plugin_object(namespace) - - if hasattr(plugin, "fire_plugin_event"): - # We assume that the event will come back to us via the plugin - return await plugin.fire_plugin_event(event, namespace, **kwargs) - else: - # Just fire the event locally - await self.AD.events.process_event(namespace, {"event_type": event, "data": kwargs}) + match plugin: + case PluginBase() as plugin: + if hasattr(plugin, "fire_plugin_event"): + # We assume that the event will come back to us via the plugin + return await plugin.fire_plugin_event(event, namespace, **kwargs) + else: + # Just fire the event locally + await self.AD.events.process_event(namespace, {"event_type": event, "data": kwargs}) async def process_event(self, namespace: str, data: dict[str, Any]): """Processes an event that has been received either locally or from a plugin. diff --git a/appdaemon/plugin_management.py b/appdaemon/plugin_management.py index ec5a0eb1c..2a4d73985 100644 --- a/appdaemon/plugin_management.py +++ b/appdaemon/plugin_management.py @@ -4,7 +4,7 @@ import importlib import sys import traceback -from collections.abc import Generator, Iterable +from collections.abc import Generator, Iterable, Mapping from logging import Logger from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, Type @@ -138,7 +138,13 @@ async def remove_entity(self, namespace: str, entity: str) -> None: # pass # @abc.abstractmethod - async def fire_plugin_event(self): + async def fire_plugin_event( + self, + event: str, + namespace: str, + timeout: str | int | float | datetime.timedelta | None = None, + **kwargs: Any, + ) -> dict[str, Any] | None: # fmt: skip raise NotImplementedError @utils.warning_decorator(error_text="Unexpected error during notify_plugin_started()") @@ -233,7 +239,7 @@ class PluginManagement: """Flag for if PluginManagement should be shutting down """ - def __init__(self, ad: "AppDaemon", config: dict[str, PluginConfig]): + def __init__(self, ad: "AppDaemon", config: Mapping[str, PluginConfig]): self.AD = ad self.config = config self.stopping = False diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index bb29627f8..d2ac14a0e 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -768,7 +768,13 @@ async def call_plugin_service( # @hass_check - async def fire_plugin_event(self, event, namespace, timeout: float | None = None, **kwargs) -> dict | None: + async def fire_plugin_event( + self, + event: str, + namespace: str, + timeout: str | int | float | datetime.timedelta | None = None, + **kwargs: Any, + ) -> dict[str, Any] | None: # fmt: skip # if we get a request for not our namespace something has gone very wrong assert namespace == self.namespace diff --git a/appdaemon/plugins/mqtt/mqttplugin.py b/appdaemon/plugins/mqtt/mqttplugin.py index 2077c415b..d016f8a8c 100644 --- a/appdaemon/plugins/mqtt/mqttplugin.py +++ b/appdaemon/plugins/mqtt/mqttplugin.py @@ -24,6 +24,7 @@ class MqttPlugin(PluginBase): mqtt_wildcards: list[str] mqtt_binary_topics: list[str] mqtt_lock: Lock + name: str = "_mqtt" def __init__(self, ad: "AppDaemon", name: str, config: MQTTConfig): """Initialize MQTT Plugin.""" diff --git a/appdaemon/scheduler.py b/appdaemon/scheduler.py index e58851220..0d76fd320 100644 --- a/appdaemon/scheduler.py +++ b/appdaemon/scheduler.py @@ -41,6 +41,7 @@ class Scheduler: schedule: dict[str, dict[str, Any]] + name: str = "_scheduler" active: bool = False realtime: bool = True stopping: bool = False diff --git a/appdaemon/state.py b/appdaemon/state.py index 6d079c5c9..122d40707 100644 --- a/appdaemon/state.py +++ b/appdaemon/state.py @@ -1,11 +1,11 @@ -from datetime import timedelta import threading import traceback import uuid from copy import copy, deepcopy +from datetime import timedelta from logging import Logger from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Set, Union, overload +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Set, overload from . import exceptions as ade from . import utils @@ -74,8 +74,8 @@ async def add_namespace( namespace: str, writeback: str, persist: bool, - name: str = None - ) -> Union[bool, Path]: + name: str | None = None, + ) -> Path | bool | None: # fmt: skip """Used to Add Namespaces from Apps""" if self.namespace_exists(namespace): @@ -533,11 +533,12 @@ async def add_entity( self, namespace: str, entity: str, - state: str | dict, + state: str | dict[str, Any], attributes: Optional[dict] = None - ): + ) -> None: """Adds an entity to the internal state registry and fires the ``__AD_ENTITY_ADDED`` event""" if self.entity_exists(namespace, entity): + self.logger.warning("%s already exists, will not be adding it", entity) return state = { diff --git a/appdaemon/stream/adstream.py b/appdaemon/stream/adstream.py index 778fbb518..be257df5a 100644 --- a/appdaemon/stream/adstream.py +++ b/appdaemon/stream/adstream.py @@ -14,6 +14,8 @@ class ADStream: + name: str = "_adstream" + def __init__(self, ad: AppDaemon, app, transport): self.AD = ad self.logger = ad.logging.get_child("_stream") @@ -80,6 +82,8 @@ async def process_event(self, data): # noqa: C901 ## directly. Only Create public methods here if you wish to make them ## stream commands. class RequestHandler: + name: str = "_request_handler" + def __init__(self, ad: AppDaemon, adstream, handle, request): self.AD = ad self.handle = handle diff --git a/appdaemon/stream/sockjs_handler.py b/appdaemon/stream/sockjs_handler.py index 0a3c16916..595c411e7 100644 --- a/appdaemon/stream/sockjs_handler.py +++ b/appdaemon/stream/sockjs_handler.py @@ -1,5 +1,6 @@ -import traceback import json +import traceback + import sockjs from appdaemon import utils as utils @@ -49,6 +50,8 @@ def makeStream(self, ad, request, **kwargs): class SockJSStream: + name: str = "_sockjsstream" + def __init__(self, ad, session, **kwargs): self.AD = ad self.session = session diff --git a/appdaemon/utils.py b/appdaemon/utils.py index 1e3ef16e2..bcf7cb32c 100644 --- a/appdaemon/utils.py +++ b/appdaemon/utils.py @@ -91,7 +91,7 @@ class PersistentDict(shelve.DbfilenameShelf): Dict-like object that uses a Shelf to persist its contents. """ - def __init__(self, filename: Path, safe: bool, *args, **kwargs): + def __init__(self, filename: str | Path, safe: bool, *args, **kwargs): filename = Path(filename).resolve().as_posix() # writeback=True allows for mutating objects in place, like with a dict. super().__init__(filename, writeback=True) @@ -321,7 +321,7 @@ def parse_timedelta(s: str | int | float | timedelta | None) -> timedelta: case int() | float(): return timedelta(seconds=s) case str(): - parts = tuple(float(p.strip()) for p in re.split(r"[^\d]+", s)) + parts = tuple(float(p.strip()) for p in re.split(r"[^\d\.]+", s)) match len(parts): case 1: return timedelta(seconds=parts[0]) @@ -523,6 +523,8 @@ class Subsystem(Protocol): AD: "AppDaemon" """Reference to the top-level AppDaemon object""" logger: Logger + name: str + """Used for registering futures, and maybe other things?""" def executor_decorator(func: Callable[..., R]) -> Callable[..., Coroutine[Any, Any, R]]: diff --git a/appdaemon/version.py b/appdaemon/version.py index 4a2a185a7..d944fe34e 100644 --- a/appdaemon/version.py +++ b/appdaemon/version.py @@ -1,2 +1,2 @@ -__version__ = "4.5.8" +__version__ = "4.5.9" __version_comments__ = "" diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 6fb1d062f..78f03f274 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -1,6 +1,27 @@ # Change Log -## 4.5.8 +## 4.5.9 + +**Features** + +None + +**Fixes** + +- Passing through `timeout` kwarg in `dash_navigate` and `fire_event` +- Fixed a bug with `parse_timedelta` in cases like `"00:2.5"` +- Minor type fixes +- Added missing `name` attributes to some classes that use `run_in_executor` + +**Breaking Changes** + +None + +**Changes in Behavior** + +None + +## 4.5.8 (2025-06-03) **Features**