Skip to content

Commit 7bd2468

Browse files
committed
Merge branch 'dev'
2 parents 04e2167 + 0e3e7d9 commit 7bd2468

14 files changed

Lines changed: 236 additions & 106 deletions

File tree

.github/ISSUE_TEMPLATE/issue_report.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ body:
3030
- Home Assistant add-on
3131
- Python virtual environment
3232
- Docker container
33+
- Native python (no virtual environment)
34+
- Nix
35+
- Other
3336
validations:
3437
required: true
3538
- type: textarea

.github/workflows/build-deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ jobs:
116116
name: python-package
117117
path: dist/
118118
- name: Setup Docker buildx
119-
uses: docker/setup-buildx-action@v3.10.0
119+
uses: docker/setup-buildx-action@v3.11.1
120120
# Login against a Docker registry (only with a tag or push on `dev` branch)
121121
# https://github.com/docker/login-action
122122
- name: Log into Docker Hub

.vscode/launch.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,6 @@
1515
"justMyCode": true,
1616
"args": "-c /home/appdaemon/ad_config/dev_test"
1717
},
18-
{
19-
"name": "AppDaemon Appdir Test",
20-
"type": "debugpy",
21-
"request": "launch",
22-
"module": "appdaemon",
23-
"justMyCode": true,
24-
"args": "-c /conf"
25-
},
2618
{
2719
"name": "AppDaemon Production",
2820
"type": "debugpy",

appdaemon/adapi.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1845,7 +1845,20 @@ def register_service(self, service: str, cb: Callable, namespace: str | None = N
18451845
self.logger.debug("register_service: %s, %s", service, kwargs)
18461846

18471847
namespace = namespace or self.namespace
1848-
self.AD.services.register_service(namespace, *service.split("/"), cb, __async="auto", name=self.name, **kwargs)
1848+
try:
1849+
domain, service = service.split("/", 2)
1850+
except ValueError as e:
1851+
raise ade.DomainNotSpecified(namespace, service) from e
1852+
else:
1853+
self.AD.services.register_service(
1854+
namespace,
1855+
domain=domain,
1856+
service=service,
1857+
callback=cb,
1858+
__async="auto",
1859+
name=self.name,
1860+
**kwargs
1861+
) # fmt: skip
18491862

18501863
def deregister_service(self, service: str, namespace: str | None = None) -> bool:
18511864
"""Deregister a service that had been previously registered.
@@ -2958,7 +2971,10 @@ async def run_at(
29582971
info = await self.AD.sched._parse_time(start, self.name)
29592972
start = info["datetime"]
29602973
case dt.time():
2961-
start = dt.datetime.combine(await self.date(), start).astimezone(self.AD.tz)
2974+
if start.tzinfo is None:
2975+
start = start.replace(tzinfo=self.AD.tz)
2976+
date = await self.date()
2977+
start = dt.datetime.combine(date, start)
29622978
case dt.datetime():
29632979
...
29642980
case _:
@@ -3043,8 +3059,10 @@ async def run_daily(
30433059
info = await self.AD.sched._parse_time(start, self.name)
30443060
start, offset, sun = info["datetime"], info["offset"], info["sun"]
30453061
case dt.time():
3062+
if start.tzinfo is None:
3063+
start = start.replace(tzinfo=self.AD.tz)
30463064
date = await self.date()
3047-
start = dt.datetime.combine(date, start).astimezone(self.AD.tz)
3065+
start = dt.datetime.combine(date, start)
30483066
case dt.datetime():
30493067
...
30503068
case _:
@@ -3288,8 +3306,10 @@ def timed_callback(self, **kwargs): ... # example callback
32883306
info = await self.AD.sched._parse_time(start, self.name)
32893307
start = info["datetime"]
32903308
case dt.time():
3309+
if start.tzinfo is None:
3310+
start = start.replace(tzinfo=self.AD.tz)
32913311
date = await self.date()
3292-
start = dt.datetime.combine(date, start).astimezone(self.AD.tz)
3312+
start = dt.datetime.combine(date, start)
32933313
case dt.datetime():
32943314
...
32953315
case None:
@@ -3323,7 +3343,7 @@ async def run_at_sunset(
33233343
self,
33243344
callback: Callable,
33253345
*args,
3326-
repeat: bool = False,
3346+
repeat: bool = True,
33273347
offset: int | None = None,
33283348
random_start: int | None = None,
33293349
random_end: int | None = None,
@@ -3337,6 +3357,7 @@ async def run_at_sunset(
33373357
callback: Function to be invoked at or around sunset. It must conform to the
33383358
standard Scheduler Callback format documented `here <APPGUIDE.html#about-schedule-callbacks>`__.
33393359
*args: Arbitrary positional arguments to be provided to the callback function when it is triggered.
3360+
repeat (bool, option): Whether the callback should repeat every day. Defaults to ``True``
33403361
offset (int, optional): The time in seconds that the callback should be delayed after
33413362
sunset. A negative value will result in the callback occurring before sunset.
33423363
This parameter cannot be combined with ``random_start`` or ``random_end``.
@@ -3395,7 +3416,7 @@ async def run_at_sunrise(
33953416
self,
33963417
callback: Callable,
33973418
*args,
3398-
repeat: bool = False,
3419+
repeat: bool = True,
33993420
offset: int | None = None,
34003421
random_start: int | None = None,
34013422
random_end: int | None = None,
@@ -3408,8 +3429,8 @@ async def run_at_sunrise(
34083429
Args:
34093430
callback: Function to be invoked at or around sunrise. It must conform to the
34103431
standard Scheduler Callback format documented `here <APPGUIDE.html#about-schedule-callbacks>`__.
3411-
*args: Arbitrary positional arguments to be provided to the callback function
3412-
when it is invoked.
3432+
*args: Arbitrary positional arguments to be provided to the callback function when it is invoked.
3433+
repeat (bool, option): Whether the callback should repeat every day. Defaults to ``True``
34133434
offset (int, optional): The time in seconds that the callback should be delayed after
34143435
sunrise. A negative value will result in the callback occurring before sunrise.
34153436
This parameter cannot be combined with ``random_start`` or ``random_end``.
@@ -3475,7 +3496,7 @@ def dash_navigate(
34753496
sticky: int = 0,
34763497
deviceid: str | None = None,
34773498
dashid: str | None = None,
3478-
) -> None:
3499+
skin: str | None = None) -> None:
34793500
"""Forces all connected Dashboards to navigate to a new URL.
34803501
34813502
Args:
@@ -3496,6 +3517,7 @@ def dash_navigate(
34963517
dashid (str): If set, all devices currently on a dashboard which the title contains
34973518
the substring dashid will navigate. ex: if dashid is "kichen", it will match
34983519
devices which are on "kitchen lights", "kitchen sensors", "ipad - kitchen", etc.
3520+
skin (str): If set, the skin will change to the skin defined on the param.
34993521
35003522
Returns:
35013523
None.
@@ -3518,6 +3540,8 @@ def dash_navigate(
35183540
kwargs["deviceid"] = deviceid
35193541
if dashid is not None:
35203542
kwargs["dashid"] = dashid
3543+
if skin is not None:
3544+
kwargs["skin"] = skin
35213545
self.fire_event("ad_dashboard", timeout=timeout, **kwargs)
35223546

35233547
#

appdaemon/app_management.py

Lines changed: 80 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pstats
99
import subprocess
1010
import sys
11+
import threading
1112
import traceback
1213
from collections import OrderedDict
1314
from collections.abc import AsyncGenerator, Iterable
@@ -355,17 +356,25 @@ async def start_app(self, app_name: str):
355356
Args:
356357
app (str): Name of the app to start
357358
"""
358-
if self.app_config[app_name].disable:
359-
self.logger.debug(f"Skip starting disabled app: '{app_name}'")
360-
return
359+
match self.app_config.root.get(app_name):
360+
case AppConfig() as app_cfg:
361+
if app_cfg.disable:
362+
self.logger.debug(f"Skip starting disabled app: '{app_name}'")
363+
return
364+
case GlobalModule():
365+
self.logger.warning('Global modules cannot be started')
366+
return
367+
case None:
368+
self.logger.error('App %s not found in app_config', app_name)
369+
return
361370

362371
# first we check if running already
363372
if self.is_app_running(app_name):
364373
self.logger.warning(f"Cannot start app {app_name}, as it is already running")
365374
return
366375

367376
# assert dependencies
368-
dependencies = self.app_config.root[app_name].dependencies
377+
dependencies = app_cfg.dependencies
369378
for dep_name in dependencies:
370379
rel_path = self.app_rel_path(app_name)
371380
exc_args = (
@@ -374,19 +383,27 @@ async def start_app(self, app_name: str):
374383
dep_name,
375384
dependencies
376385
)
377-
if (dep_cfg := self.app_config.root.get(dep_name)):
378-
match dep_cfg:
379-
case AppConfig():
380-
# There is a valid app configuration for this dependency
381-
if not (obj := self.objects.get(dep_name)) or not obj.running:
382-
# If the object isn't in the self.objects dict or it's there, but not running
386+
match self.app_config.root.get(dep_name):
387+
case AppConfig():
388+
# There is a valid app configuration for this dependency
389+
match self.objects.get(dep_name):
390+
case ManagedObject(type="app") as obj:
391+
# There is an app being managed that matches the dependency
392+
if not obj.running:
393+
# But it's not running, so raise an exception
394+
raise ade.DependencyNotRunning(*exc_args)
395+
case _:
396+
# TODO: make this a different exception
383397
raise ade.DependencyNotRunning(*exc_args)
384-
case GlobalModule():
385-
module = dep_cfg.module_name
386-
if module not in sys.modules:
387-
raise ade.GlobalNotLoaded(*exc_args)
388-
else:
389-
raise ade.AppDependencyError(*exc_args)
398+
case GlobalModule() as dep_cfg:
399+
# The dependency is a legacy global module
400+
module = dep_cfg.module_name
401+
if module not in sys.modules:
402+
# The module hasn't been loaded, so raise an exception
403+
raise ade.GlobalNotLoaded(*exc_args)
404+
case _:
405+
# There was no valid configuration for the dependency
406+
raise ade.AppDependencyError(*exc_args)
390407

391408
try:
392409
await self.initialize_app(app_name)
@@ -542,10 +559,10 @@ async def terminate_sequence(self, name: str) -> bool:
542559

543560
return True
544561

545-
async def read_all(self, config_files: Iterable[Path] = None) -> AllAppConfig:
562+
async def read_all(self, config_files: Iterable[Path]) -> AllAppConfig:
546563
config_files = config_files or self.dependency_manager.config_files
547564

548-
async def config_model_factory() -> AsyncGenerator[AllAppConfig, None, None]:
565+
async def config_model_factory() -> AsyncGenerator[AllAppConfig, None]:
549566
"""Creates a generator that sets the config_path of app configs"""
550567
for path in config_files:
551568
@ade.wrap_async(self.error, self.AD.app_dir, "Reading user apps")
@@ -592,7 +609,8 @@ def update(d1: dict, d2: dict) -> dict:
592609

593610
async def check_app_config_files(self, update_actions: UpdateActions):
594611
"""Updates self.mtimes_config and self.app_config"""
595-
files = await self.get_app_config_files()
612+
# get_files_in_other_thread = utils.executor_decorator(self.get_app_config_files)
613+
files = await self.get_app_config_files_async()
596614
self.dependency_manager.app_deps.update(files)
597615

598616
# If there were config file changes
@@ -670,6 +688,7 @@ def read_config_file(self, file: Path) -> AllAppConfig:
670688
671689
This function is primarily used by the create/edit/remove app methods that write yaml files.
672690
"""
691+
assert threading.current_thread().name.startswith("ThreadPool")
673692
raw_cfg = utils.read_config_file(file, app_config=True)
674693
if not bool(raw_cfg):
675694
self.logger.warning(
@@ -833,7 +852,7 @@ def _process_import_paths(self):
833852
case 'default' | 'expert' | None:
834853
# Get unique set of the absolute paths of all the subdirectories containing python files
835854
python_file_parents = set(
836-
f.parent.resolve() for f in Path(self.AD.app_dir).rglob("*.py")
855+
f.parent.resolve() for f in self.get_python_files()
837856
)
838857

839858
# Filter out any that have __init__.py files in them
@@ -891,43 +910,65 @@ async def _init_dep_manager(self):
891910
async def safe_dep_create(self: "AppManagement"):
892911
try:
893912
self.dependency_manager = DependencyManager(
894-
python_files=await self.get_python_files(),
895-
config_files=await self.get_app_config_files()
913+
python_files=await self.get_python_files_async(),
914+
config_files=await self.get_app_config_files_async()
896915
)
897916
self.config_filecheck.mtimes = {}
898917
self.python_filecheck.mtimes = {}
899918
except ValidationError as e:
900-
raise ade.BadAppConfigFile("Error creating dependency manager") from e
919+
raise ade.DependencyManagerError("Failed to create dependency manager") from e
901920
except ade.AppDaemonException as e:
902921
raise e
903922

904923
await safe_dep_create(self)
905924

906-
@utils.executor_decorator
907-
def get_python_files(self) -> Iterable[Path]:
908-
"""Iterates through ``*.py`` in the app directory. Excludes directory names defined in exclude_dirs and with a "." character. Also excludes files that aren't readable."""
925+
def get_python_files(self) -> set[Path]:
926+
"""Get a set of valid Python files in the app directory.
927+
928+
Valid files are ones that are readable, not inside an excluded directory, and not starting with a "." character.
929+
"""
930+
assert threading.current_thread().name.startswith("ThreadPool")
909931
return set(
910-
f
911-
for f in self.AD.app_dir.resolve().rglob("*.py")
912-
if f.parent.name not in self.AD.exclude_dirs # apply exclude_dirs
913-
and "." not in f.parent.name # also excludes *.egg-info folders
914-
and os.access(f, os.R_OK) # skip unreadable files
932+
utils.recursive_get_files(
933+
base=self.AD.app_dir.resolve(),
934+
suffix=".py",
935+
exclude=set(self.AD.exclude_dirs),
936+
)
915937
)
916938

917939
@utils.executor_decorator
918-
def get_app_config_files(self) -> Iterable[Path]:
919-
"""Iterates through config files in the config directory. Excludes directory names defined in exclude_dirs and files with a "." character. Also excludes files that aren't readable."""
940+
def get_python_files_async(self) -> set[Path]:
941+
"""Get a set of valid app config files in the app directory.
942+
943+
Valid files are ones that are readable, not inside an excluded directory, and not starting with a "." character.
944+
"""
945+
return self.get_python_files()
946+
947+
def get_app_config_files(self) -> set[Path]:
948+
"""Get a set of valid app fonfig files in the app directory.
949+
950+
Valid files are ones that are readable, not inside an excluded directory, and not starting with a "." character.
951+
"""
952+
assert threading.current_thread().name.startswith("ThreadPool")
920953
return set(
921-
f
922-
for f in self.AD.app_dir.resolve().rglob(f"*{self.ext}")
923-
if f.parent.name not in self.AD.exclude_dirs # apply exclude_dirs
924-
and "." not in f.stem
925-
and os.access(f, os.R_OK) # skip unreadable files
954+
utils.recursive_get_files(
955+
base=self.AD.app_dir.resolve(),
956+
suffix=self.ext,
957+
exclude=set(self.AD.exclude_dirs),
958+
)
926959
)
927960

961+
@utils.executor_decorator
962+
def get_app_config_files_async(self) -> set[Path]:
963+
"""Get a set of valid app config files in the app directory.
964+
965+
Valid files are ones that are readable, not inside an excluded directory, and not starting with a "." character.
966+
"""
967+
return self.get_app_config_files()
968+
928969
async def check_app_python_files(self, update_actions: UpdateActions):
929970
"""Checks the python files in the app directory. Part of self.check_app_updates sequence"""
930-
files = await self.get_python_files()
971+
files = await self.get_python_files_async()
931972
self.dependency_manager.update_python_files(files)
932973

933974
# We only need to init the modules necessary for the new apps, not reloaded ones

0 commit comments

Comments
 (0)