Skip to content

Commit 4735bc6

Browse files
committed
Merge commit '5f82d18ad71f90d27a208a744d11726cc209fd44' into pydantic-hass
2 parents d1ded1e + 5f82d18 commit 4735bc6

File tree

7 files changed

+223
-121
lines changed

7 files changed

+223
-121
lines changed

appdaemon/app_management.py

Lines changed: 117 additions & 58 deletions
Large diffs are not rendered by default.

appdaemon/appdaemon.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App
141141

142142
if self.apps is True:
143143
assert self.config_dir is not None, "Config_dir not set. This is a development problem"
144-
assert self.config_dir.exists(), f"{self.config_dir} does not exist"
144+
assert self.config_dir.exists(), f"{
145+
self.config_dir} does not exist"
145146
assert os.access(
146147
self.config_dir, os.R_OK | os.X_OK
147148
), f"{self.config_dir} does not have the right permissions"
@@ -155,6 +156,8 @@ def __init__(self, logging: "Logging", loop: BaseEventLoop, ad_config_model: App
155156
self.app_dir, os.R_OK | os.W_OK | os.X_OK
156157
), f"{self.app_dir} does not have the right permissions"
157158

159+
self.logger.info(f"Using {self.app_dir} as app_dir")
160+
158161
self.app_management = AppManagement(self)
159162
self.threading = Threading(self)
160163

appdaemon/models/config/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def discriminate_app(v: Any):
7373

7474
AppOrGlobal = Annotated[
7575
Union[
76-
Annotated[AppConfig, Tag("app")],
76+
Annotated[AppConfig, Tag("app")],
7777
Annotated[GlobalModule, Tag("global")]
7878
],
7979
Field(discriminator=Discriminator(discriminate_app))

appdaemon/models/config/sequence.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
from datetime import timedelta
22
from typing import Annotated, Any, Literal, Union
33

4-
from pydantic import BaseModel, BeforeValidator, Discriminator, Field, PlainSerializer, RootModel, Tag, WrapSerializer
4+
from pydantic import BaseModel, BeforeValidator, Discriminator, Field, PlainSerializer, RootModel, Tag, ValidationError, WrapSerializer
5+
6+
7+
def validate_timedelta(v: Any):
8+
match v:
9+
case int() | float():
10+
return timedelta(seconds=v)
11+
case _:
12+
raise ValidationError(f'Invalid type for timedelta: {v}')
513

614

715
TimeType = Annotated[
816
timedelta,
9-
BeforeValidator(lambda v: timedelta(seconds=v)),
17+
BeforeValidator(validate_timedelta),
1018
PlainSerializer(lambda td: td.total_seconds())
1119
]
1220

appdaemon/sequences.py

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ def __init__(self, ad: "AppDaemon"):
2828

2929
async def run_sequence_service(self, namespace, domain, service, kwargs):
3030
if "entity_id" not in kwargs:
31-
self.logger.warning("entity_id not given in service call, so will not be executing %s", service)
31+
self.logger.warning(
32+
"entity_id not given in service call, so will not be executing %s", service)
3233
return
3334

3435
entity_id = kwargs["entity_id"]
@@ -54,7 +55,7 @@ async def add_sequences(self, sequences):
5455
if sequence_namespace is not None:
5556
attributes.update({"namespace": sequence_namespace})
5657

57-
if not self.AD.state.entity_exists("rules", entity):
58+
if not await self.AD.state.entity_exists("rules", entity):
5859
# it doesn't exist so add it
5960
await self.AD.state.add_entity(
6061
"rules",
@@ -82,7 +83,7 @@ async def remove_sequences(self, sequences):
8283
await self.cancel_sequence(sequence)
8384
await self.AD.state.remove_entity("rules", "sequence.{}".format(sequence))
8485

85-
async def run_sequence(self, _name: str, namespace: str, sequence: str | list[str]):
86+
async def run_sequence(self, _name, namespace, sequence):
8687
if isinstance(sequence, str):
8788
if "." in sequence:
8889
# the entity given
@@ -127,14 +128,15 @@ async def cancel_sequence(self, sequence):
127128
self.AD.futures.cancel_futures(name)
128129
await self.AD.state.set_state("_sequences", "rules", entity_id, state="idle")
129130

130-
async def prep_sequence(self, _name: str, namespace: str, sequence: str | list[str]):
131+
async def prep_sequence(self, _name, namespace, sequence):
131132
ephemeral_entity = False
132133
loop = False
133134

134135
if isinstance(sequence, str):
135136
entity_id = sequence
136-
if self.AD.state.entity_exists("rules", entity_id) is False:
137-
self.logger.warning('Unknown sequence "%s" in run_sequence()', sequence)
137+
if await self.AD.state.entity_exists("rules", entity_id) is False:
138+
self.logger.warning(
139+
'Unknown sequence "%s" in run_sequence()', sequence)
138140
return None
139141

140142
entity = await self.AD.state.get_state("_services", "rules", sequence, attribute="all")
@@ -146,32 +148,20 @@ async def prep_sequence(self, _name: str, namespace: str, sequence: str | list[s
146148
#
147149
# Assume it's a list with the actual commands in it
148150
#
149-
assert isinstance(sequence, list) and all(isinstance(s, str) for s in sequence)
150151
entity_id = "sequence.{}".format(uuid.uuid4().hex)
151152
# Create an ephemeral entity for it
152153
ephemeral_entity = True
153154

154-
await self.AD.state.add_entity(
155-
namespace="rules",
156-
entity=entity_id,
157-
state="idle",
158-
attributes={"steps": sequence}
159-
)
155+
await self.AD.state.add_entity("rules", entity_id, "idle", attributes={"steps": sequence})
160156

161157
seq = sequence
162158
ns = namespace
163159

164160
coro = await self.do_steps(ns, entity_id, seq, ephemeral_entity, loop)
165161
return coro
166162

167-
async def do_steps(self,
168-
namespace: str,
169-
entity_id: str,
170-
seq: str | list[str],
171-
ephemeral_entity: bool = False,
172-
loop: bool = False):
163+
async def do_steps(self, namespace, entity_id, seq, ephemeral_entity, loop):
173164
await self.AD.state.set_state("_sequences", "rules", entity_id, state="active")
174-
175165
try:
176166
while True:
177167
steps = copy.deepcopy(seq)
@@ -191,7 +181,8 @@ async def do_steps(self,
191181

192182
elif command == "wait_state":
193183
if ephemeral_entity is True:
194-
self.logger.warning("Cannot process command 'wait_state', as not supported in sequence")
184+
self.logger.warning(
185+
"Cannot process command 'wait_state', as not supported in sequence")
195186
continue
196187

197188
_, entity_name = entity_id.split(".")
@@ -200,7 +191,8 @@ async def do_steps(self,
200191
wait_entity = parameters.get("entity_id")
201192

202193
if wait_entity is None:
203-
self.logger.warning("Cannot process command 'wait_state', as entity_id not given")
194+
self.logger.warning(
195+
"Cannot process command 'wait_state', as entity_id not given")
204196
continue
205197

206198
state = parameters.get("state")
@@ -209,30 +201,43 @@ async def do_steps(self,
209201
timeout = parameters.get("timeout", 15 * 60)
210202

211203
# now we create the wait entity object
212-
entity_object = Entity(self.logger, self.AD, name, ns, wait_entity)
213-
if not entity_object.exists():
204+
entity_object = Entity(
205+
self.logger, self.AD, name, ns, wait_entity)
206+
if not await entity_object.exists():
214207
self.logger.warning(
215-
f"Waiting for an entity {wait_entity}, in sequence {entity_name}, that doesn't exist"
208+
f"Waiting for an entity {wait_entity}, in sequence {
209+
entity_name}, that doesn't exist"
216210
)
217211

218212
try:
219213
await entity_object.wait_state(state, attribute, duration, timeout)
220214
except TimeOutException:
221215
self.logger.warning(
222-
f"{entity_name} sequence wait for {wait_entity} timed out, so continuing sequence"
216+
f"{entity_name} sequence wait for {
217+
wait_entity} timed out, so continuing sequence"
223218
)
224219

225220
else:
226221
domain, service = str.split(command, "/")
222+
# parameters["__name"] = entity_id
227223
loop_step = parameters.pop("loop_step", None)
228224
params = copy.deepcopy(parameters)
229225
await self.AD.services.call_service(ns, domain, service, entity_id, params)
230226

231-
if isinstance(loop_step, dict): # we need to loop this command multiple times
227+
# we need to loop this command multiple times
228+
if isinstance(loop_step, dict):
232229
await self.loop_step(ns, command, parameters, loop_step)
233230

234231
if loop is not True:
235232
break
233+
234+
except Exception:
235+
self.logger.error("-" * 60)
236+
self.logger.error("Unexpected error in do_steps()")
237+
self.logger.error("-" * 60)
238+
self.logger.error(traceback.format_exc())
239+
self.logger.error("-" * 60)
240+
236241
finally:
237242
await self.AD.state.set_state("_sequences", "rules", entity_id, state="idle")
238243

@@ -257,7 +262,13 @@ async def loop_step(self, namespace: str, command: str, parameters: dict, loop_s
257262

258263
except Exception:
259264
self.logger.error("-" * 60)
260-
self.logger.error("Unexpected error when attempting to loop step")
265+
self.logger.error("Unexpected error in loop_step()")
261266
self.logger.error("-" * 60)
262267
self.logger.error(traceback.format_exc())
263268
self.logger.error("-" * 60)
269+
270+
#
271+
# Placeholder for constraints
272+
#
273+
def list_constraints(self):
274+
return []

appdaemon/services.py

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,12 @@ def register_service(self, namespace: str, domain: str, service: str, callback:
8585
name = kwargs.get("__name")
8686
# first we confirm if the namespace exists
8787
if name and namespace not in self.AD.state.state:
88-
raise NamespaceException(f"Namespace {namespace}, doesn't exist")
88+
raise NamespaceException(
89+
f"Namespace {namespace}, doesn't exist")
8990

9091
elif not callable(callback):
91-
raise ValueError(f"The given callback {callback} is not a callable function")
92+
raise ValueError(f"The given callback {
93+
callback} is not a callable function")
9294

9395
if namespace not in self.services:
9496
self.services[namespace] = {}
@@ -99,27 +101,32 @@ def register_service(self, namespace: str, domain: str, service: str, callback:
99101
if service in self.services[namespace][domain]:
100102
# there was a service already registered before
101103
# so if a different app, we ask to deregister first
102-
service_app = self.services[namespace][domain][service].get("__name")
104+
service_app = self.services[namespace][domain][service].get(
105+
"__name")
103106
if service_app and service_app != name:
104107
self.logger.warning(
105-
f"This service '{domain}/{service}' already registered to a different app '{service_app}', and so cannot be registered to {name}. Do deregister from app first"
108+
f"This service '{domain}/{service}' already registered to a different app '{
109+
service_app}', and so cannot be registered to {name}. Do deregister from app first"
106110
)
107111
return
108112

109-
self.services[namespace][domain][service] = {"callback": callback, "__name": name, **kwargs}
113+
self.services[namespace][domain][service] = {
114+
"callback": callback, "__name": name, **kwargs}
110115

111116
if __silent is False:
112117
data = {
113118
"event_type": "service_registered",
114119
"data": {"namespace": namespace, "domain": domain, "service": service},
115120
}
116-
self.AD.loop.create_task(self.AD.events.process_event(namespace, data))
121+
self.AD.loop.create_task(
122+
self.AD.events.process_event(namespace, data))
117123

118124
if name:
119125
if name not in self.app_registered_services:
120126
self.app_registered_services[name] = set()
121127

122-
self.app_registered_services[name].add(f"{namespace}:{domain}:{service}")
128+
self.app_registered_services[name].add(
129+
f"{namespace}:{domain}:{service}")
123130

124131
def deregister_service(self, namespace: str, domain: str, service: str, __name: str) -> bool:
125132
"""Used to unregister a service"""
@@ -133,12 +140,14 @@ def deregister_service(self, namespace: str, domain: str, service: str, __name:
133140
)
134141

135142
if __name not in self.app_registered_services:
136-
raise ValueError(f"The given App {__name} has no services registered")
143+
raise ValueError(f"The given App {
144+
__name} has no services registered")
137145

138146
app_service = f"{namespace}:{domain}:{service}"
139147

140148
if app_service not in self.app_registered_services[__name]:
141-
raise ValueError(f"The given App {__name} doesn't have the given service registered it")
149+
raise ValueError(f"The given App {
150+
__name} doesn't have the given service registered it")
142151

143152
# if it gets here, then time to deregister
144153
with self.services_lock:
@@ -149,7 +158,8 @@ def deregister_service(self, namespace: str, domain: str, service: str, __name:
149158
"event_type": "service_deregistered",
150159
"data": {"namespace": namespace, "domain": domain, "service": service, "app": __name},
151160
}
152-
self.AD.loop.create_task(self.AD.events.process_event(namespace, data))
161+
self.AD.loop.create_task(
162+
self.AD.events.process_event(namespace, data))
153163

154164
# now check if that domain is empty
155165
# if it is, remove it also
@@ -192,29 +202,37 @@ def list_services(self, ns: str = "global") -> list[dict[str, str]]:
192202
]
193203

194204
async def call_service(
195-
self,
196-
namespace: str,
197-
domain: str,
198-
service: str,
199-
name: str | None = None,
200-
data: dict[str, Any] | None = None, # Don't expand with **data
201-
) -> Any:
205+
self,
206+
namespace: str,
207+
domain: str,
208+
service: str,
209+
name: str | None = None,
210+
data: dict[str, Any] | None = None, # Don't expand with **data
211+
) -> Any:
202212
self.logger.debug(
203213
"call_service: namespace=%s domain=%s service=%s data=%s",
204214
namespace,
205215
domain,
206216
service,
207217
data,
208218
)
219+
220+
# data can be None, later on we assume it is not!
221+
if data is None:
222+
data = {}
223+
209224
with self.services_lock:
210225
if namespace not in self.services:
211-
raise NamespaceException(f"Unknown namespace {namespace} in call_service from {name}")
226+
raise NamespaceException(f"Unknown namespace {
227+
namespace} in call_service from {name}")
212228

213229
if domain not in self.services[namespace]:
214-
raise DomainException(f"Unknown domain ({namespace}/{domain}) in call_service from {name}")
230+
raise DomainException(
231+
f"Unknown domain ({namespace}/{domain}) in call_service from {name}")
215232

216233
if service not in self.services[namespace][domain]:
217-
raise ServiceException(f"Unknown service ({namespace}/{domain}/{service}) in call_service from {name}")
234+
raise ServiceException(
235+
f"Unknown service ({namespace}/{domain}/{service}) in call_service from {name}")
218236

219237
# If we have namespace in data it's an override for the domain of the eventual service call, as distinct
220238
# from the namespace the call itself is executed from. e.g. set_state() is in the AppDaemon namespace but
@@ -230,9 +248,10 @@ async def call_service(
230248
match isasync := service_def.pop("__async", 'auto'):
231249
case 'auto':
232250
# Remove any wrappers from the funcref before determining if it's async or not
233-
isasync = asyncio.iscoroutinefunction(utils.unwrapped(funcref))
251+
isasync = asyncio.iscoroutinefunction(
252+
utils.unwrapped(funcref))
234253
case bool():
235-
pass # isasync already set as a bool from above
254+
pass # isasync already set as a bool from above
236255
case _:
237256
raise TypeError(f'Invalid __async type: {isasync}')
238257

@@ -247,9 +266,11 @@ async def call_service(
247266
else:
248267
# It's not a coroutine, run it in an executor
249268
if use_dictionary_unpacking:
250-
coro = utils.run_in_executor(self, funcref, ns, domain, service, **data)
269+
coro = utils.run_in_executor(
270+
self, funcref, ns, domain, service, **data)
251271
else:
252-
coro = utils.run_in_executor(self, funcref, ns, domain, service, data)
272+
coro = utils.run_in_executor(
273+
self, funcref, ns, domain, service, data)
253274

254275
@utils.warning_decorator(error_text=f"Unexpected error calling service {ns}/{domain}/{service}")
255276
async def safe_service(self: 'Services'):

appdaemon/state.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -554,13 +554,13 @@ def maybe_copy(data):
554554
}
555555

556556
def parse_state(
557-
self,
558-
namespace: str,
559-
entity: str,
560-
state: Any | None = None,
561-
attributes: dict | None = None,
562-
replace: bool = False,
563-
**kwargs
557+
self,
558+
namespace: str,
559+
entity: str,
560+
state: Any | None = None,
561+
attributes: dict | None = None,
562+
replace: bool = False,
563+
**kwargs
564564
):
565565
self.logger.debug(f"parse_state: {entity}, {kwargs}")
566566

0 commit comments

Comments
 (0)