python-library-automation 0.1.16__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- automation/__init__.py +25 -0
- automation/assistant.py +141 -0
- automation/builtins/__init__.py +27 -0
- automation/builtins/action/__init__.py +0 -0
- automation/builtins/action/call_entity_method.py +35 -0
- automation/builtins/action/delay.py +18 -0
- automation/builtins/action/log.py +20 -0
- automation/builtins/action/set_attribute.py +27 -0
- automation/builtins/entity/__init__.py +0 -0
- automation/builtins/entity/time.py +29 -0
- automation/builtins/entity/variable.py +81 -0
- automation/builtins/event/__init__.py +0 -0
- automation/builtins/event/_scheduled.py +34 -0
- automation/builtins/event/at.py +27 -0
- automation/builtins/event/callback.py +65 -0
- automation/builtins/event/every.py +32 -0
- automation/builtins/event/state_changed.py +58 -0
- automation/core/__init__.py +13 -0
- automation/core/action.py +22 -0
- automation/core/base.py +59 -0
- automation/core/composite_action.py +47 -0
- automation/core/entity.py +178 -0
- automation/core/event.py +87 -0
- automation/core/event_context.py +10 -0
- automation/core/trigger.py +151 -0
- automation/errors.py +42 -0
- automation/executor.py +46 -0
- automation/hub.py +52 -0
- automation/listeners/__init__.py +17 -0
- automation/listeners/base.py +23 -0
- automation/listeners/console.py +82 -0
- automation/listeners/instance_schema.py +26 -0
- automation/listeners/record.py +29 -0
- automation/listeners/trace.py +70 -0
- automation/listeners/type_schema.py +26 -0
- automation/loader.py +131 -0
- automation/renderer.py +311 -0
- automation/schema.py +142 -0
- automation/updater.py +84 -0
- python_library_automation-0.1.16.dist-info/METADATA +8 -0
- python_library_automation-0.1.16.dist-info/RECORD +42 -0
- python_library_automation-0.1.16.dist-info/WHEEL +4 -0
automation/renderer.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
import operator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
VARIABLE_RE = re.compile(r"\{([^{}]+)\}")
|
|
7
|
+
_SINGLE_VAR_RE = re.compile(r"^\{([^{}]+)\}$")
|
|
8
|
+
|
|
9
|
+
class Renderer:
|
|
10
|
+
"""
|
|
11
|
+
渲染器 — 统一的变量解析、模板渲染、表达式求值引擎
|
|
12
|
+
|
|
13
|
+
通过 derive() 创建子渲染器来注入局部作用域,
|
|
14
|
+
不可变设计,derive 不影响父渲染器。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, hub):
|
|
18
|
+
self._hub = hub
|
|
19
|
+
self._scopes: dict[tuple[str, str], dict[str, Any]] = {}
|
|
20
|
+
|
|
21
|
+
def derive(self, type_: str, scope: str, data: dict[str, Any]) -> "Renderer":
|
|
22
|
+
"""创建带有新作用域的子渲染器"""
|
|
23
|
+
child = Renderer.__new__(Renderer)
|
|
24
|
+
child._hub = self._hub
|
|
25
|
+
child._scopes = {**self._scopes, (type_, scope): data}
|
|
26
|
+
return child
|
|
27
|
+
|
|
28
|
+
# ── 变量解析 ──
|
|
29
|
+
|
|
30
|
+
def resolve(self, token: str) -> Any:
|
|
31
|
+
"""解析 type.scope.attr_path 变量"""
|
|
32
|
+
parts = token.split(".", 2)
|
|
33
|
+
|
|
34
|
+
if len(parts) == 2:
|
|
35
|
+
type_, scope = parts
|
|
36
|
+
if type_ == "entity":
|
|
37
|
+
if scope not in self._hub.entities:
|
|
38
|
+
raise ValueError(f"Entity {scope!r} not found")
|
|
39
|
+
return self._hub.entities[scope]
|
|
40
|
+
key = (type_, scope)
|
|
41
|
+
if key in self._scopes:
|
|
42
|
+
return self._scopes[key]
|
|
43
|
+
raise ValueError(f"Cannot resolve variable: {{{token}}}")
|
|
44
|
+
|
|
45
|
+
if len(parts) < 2:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Invalid variable: {{{token}}}, "
|
|
48
|
+
f"expected {{type.scope.attribute}}"
|
|
49
|
+
)
|
|
50
|
+
type_, scope, attr_path = parts
|
|
51
|
+
|
|
52
|
+
# 局部作用域: event.local.xxx, action.local.xxx
|
|
53
|
+
key = (type_, scope)
|
|
54
|
+
if key in self._scopes:
|
|
55
|
+
return self._deep_get(self._scopes[key], attr_path)
|
|
56
|
+
|
|
57
|
+
# 全局: entity.instance_name.attr_path
|
|
58
|
+
if type_ == "entity":
|
|
59
|
+
return self._resolve_entity(scope, attr_path)
|
|
60
|
+
|
|
61
|
+
raise ValueError(f"Cannot resolve variable: {{{token}}}")
|
|
62
|
+
|
|
63
|
+
def render(self, template: str) -> str:
|
|
64
|
+
"""将 {var} 占位符替换为实际值的字符串"""
|
|
65
|
+
def replace(match):
|
|
66
|
+
token = match.group(1).strip()
|
|
67
|
+
return str(self.resolve(token))
|
|
68
|
+
return VARIABLE_RE.sub(replace, template)
|
|
69
|
+
|
|
70
|
+
def render_value(self, value: Any) -> Any:
|
|
71
|
+
"""递归解析值:纯变量引用返回原始对象,混合文本做字符串渲染"""
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
m = _SINGLE_VAR_RE.match(value.strip())
|
|
74
|
+
if m:
|
|
75
|
+
return self.resolve(m.group(1).strip())
|
|
76
|
+
return self.render(value)
|
|
77
|
+
if isinstance(value, dict):
|
|
78
|
+
return {k: self.render_value(v) for k, v in value.items()}
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
return [self.render_value(v) for v in value]
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
def eval_bool(self, expr: str) -> bool:
|
|
84
|
+
"""解析并求值布尔表达式,{var} 占位符会先解析为值"""
|
|
85
|
+
placeholders: dict[str, str] = {}
|
|
86
|
+
def replace(match):
|
|
87
|
+
token = match.group(1).strip()
|
|
88
|
+
name = f"_v{len(placeholders)}"
|
|
89
|
+
placeholders[name] = token
|
|
90
|
+
return name
|
|
91
|
+
parsed = VARIABLE_RE.sub(replace, expr)
|
|
92
|
+
tree = ast.parse(parsed, mode="eval")
|
|
93
|
+
_validate_ast(tree)
|
|
94
|
+
values = {
|
|
95
|
+
name: self.resolve(token)
|
|
96
|
+
for name, token in placeholders.items()
|
|
97
|
+
}
|
|
98
|
+
result = _safe_eval(tree.body, values)
|
|
99
|
+
if not isinstance(result, bool):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Expression must return bool, got {type(result).__name__}"
|
|
102
|
+
)
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
def validate_token(self, token: str) -> None:
|
|
106
|
+
parts = token.split(".", 2)
|
|
107
|
+
if len(parts) < 2:
|
|
108
|
+
raise ValueError(f"Invalid variable format: {{{token}}}")
|
|
109
|
+
|
|
110
|
+
if len(parts) == 2:
|
|
111
|
+
type_, scope = parts
|
|
112
|
+
if type_ == "entity":
|
|
113
|
+
if scope not in self._hub.entities:
|
|
114
|
+
raise ValueError(f"Entity {scope!r} not found")
|
|
115
|
+
return
|
|
116
|
+
if type_ in ("event", "action") and scope == "local":
|
|
117
|
+
return
|
|
118
|
+
raise ValueError(f"Unknown variable namespace: {type_}.{scope}")
|
|
119
|
+
|
|
120
|
+
type_, scope, attr_path = parts
|
|
121
|
+
if type_ == "entity":
|
|
122
|
+
if scope not in self._hub.entities:
|
|
123
|
+
raise ValueError(f"Entity {scope!r} not found")
|
|
124
|
+
entity = self._hub.entities[scope]
|
|
125
|
+
attr_root = attr_path.split(".")[0]
|
|
126
|
+
known = {a.name for a in entity.get_attributes()}
|
|
127
|
+
if attr_root not in known:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Entity {scope!r} has no attribute {attr_root!r}, "
|
|
130
|
+
f"available: {', '.join(sorted(known))}"
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
if type_ in ("event", "action") and scope == "local":
|
|
134
|
+
return
|
|
135
|
+
raise ValueError(f"Unknown variable namespace: {type_}.{scope}")
|
|
136
|
+
|
|
137
|
+
def validate_template(self, template: str) -> None:
|
|
138
|
+
for match in VARIABLE_RE.finditer(template):
|
|
139
|
+
self.validate_token(match.group(1).strip())
|
|
140
|
+
|
|
141
|
+
def validate_expr(self, expr: str) -> None:
|
|
142
|
+
self.validate_template(expr)
|
|
143
|
+
|
|
144
|
+
cleaned = VARIABLE_RE.sub("True", expr)
|
|
145
|
+
try:
|
|
146
|
+
tree = ast.parse(cleaned, mode="eval")
|
|
147
|
+
except SyntaxError as e:
|
|
148
|
+
raise ValueError(f"Syntax error in expression: {expr!r}") from e
|
|
149
|
+
_validate_ast(tree)
|
|
150
|
+
|
|
151
|
+
def _resolve_entity(self, name: str, attr_path: str) -> Any:
|
|
152
|
+
if name not in self._hub.entities:
|
|
153
|
+
raise ValueError(f"Entity {name!r} not found")
|
|
154
|
+
entity = self._hub.entities[name]
|
|
155
|
+
parts = attr_path.split(".")
|
|
156
|
+
|
|
157
|
+
values = entity.get_attribute_values()
|
|
158
|
+
first = parts[0]
|
|
159
|
+
if first not in values:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"{entity.__class__.__name__} {name!r} has no attribute path {attr_path!r}"
|
|
162
|
+
)
|
|
163
|
+
obj = values[first]
|
|
164
|
+
|
|
165
|
+
for part in parts[1:]:
|
|
166
|
+
if not hasattr(obj, part):
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"{entity.__class__.__name__} {name!r} has no attribute path {attr_path!r}"
|
|
169
|
+
)
|
|
170
|
+
obj = getattr(obj, part)
|
|
171
|
+
return obj
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _deep_get(data: dict, path: str) -> Any:
|
|
175
|
+
"""从 dict 中按 . 分隔路径取值"""
|
|
176
|
+
current = data
|
|
177
|
+
for part in path.split("."):
|
|
178
|
+
if isinstance(current, dict):
|
|
179
|
+
if part not in current:
|
|
180
|
+
raise ValueError(f"Key {part!r} not found in scope data")
|
|
181
|
+
current = current[part]
|
|
182
|
+
else:
|
|
183
|
+
if not hasattr(current, part):
|
|
184
|
+
raise ValueError(f"Attribute {part!r} not found")
|
|
185
|
+
current = getattr(current, part)
|
|
186
|
+
return current
|
|
187
|
+
|
|
188
|
+
_SAFE_NODES = (
|
|
189
|
+
ast.Expression, ast.BoolOp, ast.Compare, ast.UnaryOp,
|
|
190
|
+
ast.Constant, ast.Name, ast.Load, ast.Store, ast.And, ast.Or, ast.Not,
|
|
191
|
+
ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
|
|
192
|
+
ast.Is, ast.IsNot, ast.In, ast.NotIn,
|
|
193
|
+
ast.List, ast.Tuple,
|
|
194
|
+
ast.Call,
|
|
195
|
+
ast.Attribute,
|
|
196
|
+
ast.ListComp, ast.GeneratorExp, ast.comprehension,
|
|
197
|
+
)
|
|
198
|
+
_SAFE_FUNCTIONS = frozenset({"any", "all"})
|
|
199
|
+
|
|
200
|
+
_CMP_OPS = {
|
|
201
|
+
ast.Eq: operator.eq, ast.NotEq: operator.ne,
|
|
202
|
+
ast.Lt: operator.lt, ast.LtE: operator.le,
|
|
203
|
+
ast.Gt: operator.gt, ast.GtE: operator.ge,
|
|
204
|
+
ast.Is: operator.is_, ast.IsNot: operator.is_not,
|
|
205
|
+
ast.In: lambda a, b: a in b,
|
|
206
|
+
ast.NotIn: lambda a, b: a not in b,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
def _validate_ast(tree: ast.AST) -> None:
|
|
210
|
+
for node in ast.walk(tree):
|
|
211
|
+
if not isinstance(node, _SAFE_NODES):
|
|
212
|
+
raise ValueError(f"Unsupported expression node: {type(node).__name__}")
|
|
213
|
+
if isinstance(node, ast.Call):
|
|
214
|
+
if (
|
|
215
|
+
not isinstance(node.func, ast.Name)
|
|
216
|
+
or node.func.id not in _SAFE_FUNCTIONS
|
|
217
|
+
):
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Unsupported function: only {', '.join(_SAFE_FUNCTIONS)} allowed"
|
|
220
|
+
)
|
|
221
|
+
if node.keywords:
|
|
222
|
+
raise ValueError("Keyword arguments not supported in expressions")
|
|
223
|
+
if isinstance(node, ast.Attribute):
|
|
224
|
+
if node.attr.startswith("_"):
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"Access to private attribute {node.attr!r} not allowed"
|
|
227
|
+
)
|
|
228
|
+
if isinstance(node, ast.comprehension):
|
|
229
|
+
if not isinstance(node.target, ast.Name):
|
|
230
|
+
raise ValueError(
|
|
231
|
+
"Only simple variable names allowed in comprehension target"
|
|
232
|
+
)
|
|
233
|
+
if node.is_async:
|
|
234
|
+
raise ValueError("Async comprehensions not supported")
|
|
235
|
+
|
|
236
|
+
def _safe_eval(node: ast.AST, values: dict[str, Any]) -> Any:
|
|
237
|
+
if isinstance(node, ast.BoolOp):
|
|
238
|
+
if isinstance(node.op, ast.And):
|
|
239
|
+
return all(_safe_eval(v, values) for v in node.values)
|
|
240
|
+
return any(_safe_eval(v, values) for v in node.values)
|
|
241
|
+
|
|
242
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
|
|
243
|
+
return not _safe_eval(node.operand, values)
|
|
244
|
+
|
|
245
|
+
if isinstance(node, ast.Compare):
|
|
246
|
+
left = _safe_eval(node.left, values)
|
|
247
|
+
for op, comparator in zip(node.ops, node.comparators):
|
|
248
|
+
right = _safe_eval(comparator, values)
|
|
249
|
+
op_func = _CMP_OPS.get(type(op))
|
|
250
|
+
if op_func is None:
|
|
251
|
+
raise ValueError(f"Unsupported compare op: {type(op).__name__}")
|
|
252
|
+
if not op_func(left, right):
|
|
253
|
+
return False
|
|
254
|
+
left = right
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
if isinstance(node, ast.Constant):
|
|
258
|
+
return node.value
|
|
259
|
+
|
|
260
|
+
if isinstance(node, ast.Name):
|
|
261
|
+
if node.id not in values:
|
|
262
|
+
raise ValueError(f"Unknown variable: {node.id}")
|
|
263
|
+
return values[node.id]
|
|
264
|
+
|
|
265
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
266
|
+
return [_safe_eval(el, values) for el in node.elts]
|
|
267
|
+
|
|
268
|
+
if isinstance(node, ast.Attribute):
|
|
269
|
+
obj = _safe_eval(node.value, values)
|
|
270
|
+
if not hasattr(obj, node.attr):
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"{type(obj).__name__!r} has no attribute {node.attr!r}"
|
|
273
|
+
)
|
|
274
|
+
return getattr(obj, node.attr)
|
|
275
|
+
|
|
276
|
+
if isinstance(node, (ast.ListComp, ast.GeneratorExp)):
|
|
277
|
+
return _eval_comprehension(node.generators, 0, node.elt, values)
|
|
278
|
+
|
|
279
|
+
if isinstance(node, ast.Call):
|
|
280
|
+
func_name = node.func.id
|
|
281
|
+
args = [_safe_eval(a, values) for a in node.args]
|
|
282
|
+
fn = any if func_name == "any" else all
|
|
283
|
+
if len(args) == 1:
|
|
284
|
+
return fn(args[0])
|
|
285
|
+
if len(args) == 2:
|
|
286
|
+
items, attr = args
|
|
287
|
+
return fn(getattr(item, attr, False) for item in items)
|
|
288
|
+
raise ValueError(f"{func_name}() takes 1 or 2 arguments, got {len(args)}")
|
|
289
|
+
|
|
290
|
+
raise ValueError(f"Cannot evaluate node: {type(node).__name__}")
|
|
291
|
+
|
|
292
|
+
def _eval_comprehension(
|
|
293
|
+
generators: list[ast.comprehension],
|
|
294
|
+
gen_idx: int,
|
|
295
|
+
elt: ast.AST,
|
|
296
|
+
values: dict[str, Any],
|
|
297
|
+
) -> list:
|
|
298
|
+
"""递归求值推导式(支持多层 for 和 if 过滤)"""
|
|
299
|
+
if gen_idx >= len(generators):
|
|
300
|
+
return [_safe_eval(elt, values)]
|
|
301
|
+
gen = generators[gen_idx]
|
|
302
|
+
target_name = gen.target.id
|
|
303
|
+
iterable = _safe_eval(gen.iter, values)
|
|
304
|
+
results = []
|
|
305
|
+
for item in iterable:
|
|
306
|
+
inner = {**values, target_name: item}
|
|
307
|
+
if all(_safe_eval(cond, inner) for cond in gen.ifs):
|
|
308
|
+
results.extend(
|
|
309
|
+
_eval_comprehension(generators, gen_idx + 1, elt, inner)
|
|
310
|
+
)
|
|
311
|
+
return results
|
automation/schema.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from automation.core.entity import (
|
|
5
|
+
entity_registry, introspect_attributes, introspect_methods,
|
|
6
|
+
)
|
|
7
|
+
from automation.core.event import event_registry
|
|
8
|
+
from automation.core.action import action_registry
|
|
9
|
+
from automation.core.trigger import trigger_registry
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from automation.hub import Hub
|
|
13
|
+
|
|
14
|
+
SECTION_REGISTRIES = {
|
|
15
|
+
"entities": entity_registry,
|
|
16
|
+
"events": event_registry,
|
|
17
|
+
"actions": action_registry,
|
|
18
|
+
"triggers": trigger_registry,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _field_schema(field_info) -> dict[str, Any]:
|
|
23
|
+
result: dict[str, Any] = {}
|
|
24
|
+
if field_info.annotation is not None:
|
|
25
|
+
result["type"] = getattr(field_info.annotation, "__name__", str(field_info.annotation))
|
|
26
|
+
if field_info.description:
|
|
27
|
+
result["description"] = field_info.description
|
|
28
|
+
if field_info.default is not None:
|
|
29
|
+
result["default"] = field_info.default
|
|
30
|
+
result["required"] = field_info.is_required()
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def export_type_schema() -> dict[str, Any]:
|
|
35
|
+
result: dict[str, Any] = {}
|
|
36
|
+
|
|
37
|
+
for section, registry in SECTION_REGISTRIES.items():
|
|
38
|
+
section_types = {}
|
|
39
|
+
for reg_name in registry.get_registered_names():
|
|
40
|
+
short = reg_name.split(".")[-1]
|
|
41
|
+
cls = registry.get(short)
|
|
42
|
+
|
|
43
|
+
config_fields = {}
|
|
44
|
+
for name, field_info in cls.model_fields.items():
|
|
45
|
+
if name == "instance_name":
|
|
46
|
+
continue
|
|
47
|
+
config_fields[name] = _field_schema(field_info)
|
|
48
|
+
|
|
49
|
+
type_info: dict[str, Any] = {"config_fields": config_fields}
|
|
50
|
+
|
|
51
|
+
if section == "entities":
|
|
52
|
+
type_info["attributes"] = [
|
|
53
|
+
{
|
|
54
|
+
"name": a.name, "type": a.type,
|
|
55
|
+
"description": a.description,
|
|
56
|
+
"readonly": a.readonly, "default": a.default,
|
|
57
|
+
}
|
|
58
|
+
for a in introspect_attributes(cls)
|
|
59
|
+
]
|
|
60
|
+
type_info["methods"] = [
|
|
61
|
+
{
|
|
62
|
+
"name": m.name, "description": m.description,
|
|
63
|
+
"params": m.params, "return_type": m.return_type,
|
|
64
|
+
}
|
|
65
|
+
for m in introspect_methods(cls)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
section_types[short] = type_info
|
|
69
|
+
result[section] = section_types
|
|
70
|
+
|
|
71
|
+
result["composite_actions"] = {
|
|
72
|
+
"description": "可复用的命名组合动作",
|
|
73
|
+
"fields": {
|
|
74
|
+
"params": {"type": "object", "description": "参数声明 {名称: 类型}"},
|
|
75
|
+
"conditions": {"type": "array", "description": "条件表达式列表"},
|
|
76
|
+
"actions": {"type": "array", "description": "子动作规格列表"},
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def export_instance_schema(hub: Hub) -> dict[str, Any]:
|
|
83
|
+
from automation.core.entity import Entity
|
|
84
|
+
|
|
85
|
+
result: dict[str, Any] = {}
|
|
86
|
+
|
|
87
|
+
entities = {}
|
|
88
|
+
for name, entity in hub.entities.items():
|
|
89
|
+
info: dict[str, Any] = {"type": entity._type}
|
|
90
|
+
if isinstance(entity, Entity):
|
|
91
|
+
info["attributes"] = {
|
|
92
|
+
attr.name: {
|
|
93
|
+
"type": attr.type,
|
|
94
|
+
"value": getattr(entity, attr.name, None),
|
|
95
|
+
"readonly": attr.readonly,
|
|
96
|
+
"description": attr.description,
|
|
97
|
+
}
|
|
98
|
+
for attr in entity.get_attributes()
|
|
99
|
+
}
|
|
100
|
+
info["methods"] = [
|
|
101
|
+
{
|
|
102
|
+
"name": m.name, "description": m.description,
|
|
103
|
+
"params": m.params, "return_type": m.return_type,
|
|
104
|
+
}
|
|
105
|
+
for m in entity.get_methods()
|
|
106
|
+
]
|
|
107
|
+
entities[name] = info
|
|
108
|
+
result["entities"] = entities
|
|
109
|
+
|
|
110
|
+
events = {}
|
|
111
|
+
for name, event in hub.events.items():
|
|
112
|
+
event_info: dict[str, Any] = {"type": event._type}
|
|
113
|
+
config = {}
|
|
114
|
+
for fname, finfo in event.model_fields.items():
|
|
115
|
+
if fname == "instance_name":
|
|
116
|
+
continue
|
|
117
|
+
config[fname] = getattr(event, fname)
|
|
118
|
+
event_info["config"] = config
|
|
119
|
+
events[name] = event_info
|
|
120
|
+
result["events"] = events
|
|
121
|
+
|
|
122
|
+
triggers = {}
|
|
123
|
+
for name, trigger in hub.triggers.items():
|
|
124
|
+
triggers[name] = {
|
|
125
|
+
"type": trigger._type,
|
|
126
|
+
"event": trigger.event,
|
|
127
|
+
"mode": trigger.mode,
|
|
128
|
+
"conditions": trigger.conditions,
|
|
129
|
+
"actions_count": len(trigger.actions),
|
|
130
|
+
}
|
|
131
|
+
result["triggers"] = triggers
|
|
132
|
+
|
|
133
|
+
actions = {}
|
|
134
|
+
for name, composite in hub.actions.items():
|
|
135
|
+
actions[name] = {
|
|
136
|
+
"params": composite.params,
|
|
137
|
+
"conditions": composite.conditions,
|
|
138
|
+
"actions_count": len(composite.action_specs),
|
|
139
|
+
}
|
|
140
|
+
result["actions"] = actions
|
|
141
|
+
|
|
142
|
+
return result
|
automation/updater.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
from automation.hub import Hub, State
|
|
6
|
+
from automation.loader import build_section, build_actions
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def apply_diff(
|
|
12
|
+
hub: Hub, old: dict[str, Any], new: dict[str, Any]
|
|
13
|
+
) -> None:
|
|
14
|
+
changed_refs: set[tuple[str, str]] = set()
|
|
15
|
+
|
|
16
|
+
all_sections = (*hub.AUTOMATION_SECTIONS, "actions")
|
|
17
|
+
for section_name in all_sections:
|
|
18
|
+
old_section = old.get(section_name, {})
|
|
19
|
+
new_section = new.get(section_name, {})
|
|
20
|
+
for name in set(old_section) | set(new_section):
|
|
21
|
+
if old_section.get(name) != new_section.get(name):
|
|
22
|
+
changed_refs.add((section_name, name))
|
|
23
|
+
|
|
24
|
+
# --- automation sections (entities, events, triggers) ---
|
|
25
|
+
for section_name in hub.AUTOMATION_SECTIONS:
|
|
26
|
+
old_section = old.get(section_name, {})
|
|
27
|
+
new_section = new.get(section_name, {})
|
|
28
|
+
current = hub.section(section_name)
|
|
29
|
+
|
|
30
|
+
for name in list(current):
|
|
31
|
+
if name not in new_section:
|
|
32
|
+
obj = current.pop(name)
|
|
33
|
+
await obj.on_stop()
|
|
34
|
+
|
|
35
|
+
for name, spec in new_section.items():
|
|
36
|
+
old_spec = old_section.get(name)
|
|
37
|
+
if name in current and old_spec == spec:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
if name in current:
|
|
41
|
+
obj = current[name]
|
|
42
|
+
if hub.state == State.RUNNING:
|
|
43
|
+
await obj.on_stop()
|
|
44
|
+
raw_spec = dict(spec)
|
|
45
|
+
raw_spec.pop("type", None)
|
|
46
|
+
await obj.update(hub, raw_spec)
|
|
47
|
+
await obj.on_validate(hub)
|
|
48
|
+
await obj.on_update(hub)
|
|
49
|
+
await obj.on_activate(hub)
|
|
50
|
+
if hub.state == State.RUNNING:
|
|
51
|
+
await obj.on_start()
|
|
52
|
+
else:
|
|
53
|
+
built = build_section(section_name, {name: spec})
|
|
54
|
+
obj = built[name]
|
|
55
|
+
obj._hub = hub
|
|
56
|
+
await obj.on_validate(hub)
|
|
57
|
+
await obj.on_activate(hub)
|
|
58
|
+
current[name] = obj
|
|
59
|
+
if hub.state == State.RUNNING:
|
|
60
|
+
await obj.on_start()
|
|
61
|
+
|
|
62
|
+
# --- actions (CompositeAction) ---
|
|
63
|
+
old_actions_cfg = old.get("actions", {})
|
|
64
|
+
new_actions_cfg = new.get("actions", {})
|
|
65
|
+
|
|
66
|
+
for name in list(hub.actions):
|
|
67
|
+
if name not in new_actions_cfg:
|
|
68
|
+
del hub.actions[name]
|
|
69
|
+
|
|
70
|
+
if new_actions_cfg != old_actions_cfg:
|
|
71
|
+
new_composites = build_actions(new_actions_cfg)
|
|
72
|
+
hub.actions = new_composites
|
|
73
|
+
for composite in hub.actions.values():
|
|
74
|
+
composite.validate(hub)
|
|
75
|
+
|
|
76
|
+
# --- refresh affected triggers ---
|
|
77
|
+
for trigger in hub.triggers.values():
|
|
78
|
+
refs = {("events", trigger.event)}
|
|
79
|
+
for spec in trigger.actions:
|
|
80
|
+
type_name = spec.get("type", "")
|
|
81
|
+
refs.add(("actions", type_name))
|
|
82
|
+
if refs & changed_refs:
|
|
83
|
+
await trigger.on_validate(hub)
|
|
84
|
+
await trigger.on_activate(hub)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
automation/__init__.py,sha256=gowCERBmUVqZ6aavHlXikw9emojRoa3XRf9SbkcyH2Q,551
|
|
2
|
+
automation/assistant.py,sha256=7mlqPpckFlGm5CGWl_m9pydhK9CID-1Nt-gCuHo2RS0,4870
|
|
3
|
+
automation/errors.py,sha256=ATAR5gMWFme3uqpN-ivMH6of1g7jTcfrdtGxCDxj7cs,1096
|
|
4
|
+
automation/executor.py,sha256=ELThuvCRpN01dfM5xkxcgjfRDNuhlqF3tXMPp3pTqA8,1340
|
|
5
|
+
automation/hub.py,sha256=L1IXxqO2TBmzMi8soSuLVKCoXCQrFgwnBRKOxWw35nI,1799
|
|
6
|
+
automation/loader.py,sha256=RW76kVCfGiUYi8l-zwAVZbrLlGwtywNCSwvCpn8KUX4,4557
|
|
7
|
+
automation/renderer.py,sha256=bM-9tNGzU5jGNZNzA_JxaoggSZBGEZ9cfTfYuLbeYcU,11892
|
|
8
|
+
automation/schema.py,sha256=TJRm5Dij70dYLZ1hjuxt2Hn11pUVwBy97zz181XX03I,4944
|
|
9
|
+
automation/updater.py,sha256=8KRkC4LCZ8SFVhB6HPPW9HxvLfQdk1ea1fmZV5fRhB4,3062
|
|
10
|
+
automation/builtins/__init__.py,sha256=Azwf9VF0SI97Pt9wf-SD1zmedVyAEyQbo1WGliq5qW0,886
|
|
11
|
+
automation/builtins/action/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
automation/builtins/action/call_entity_method.py,sha256=cjz_wal37AUapKPTmtMWVmP0EiF1cjaSSW1XtSXFBf8,1247
|
|
13
|
+
automation/builtins/action/delay.py,sha256=x5EMoG1Hr0y2kuJLawYMHaoYZG5gvuwITUfM-C1zHDk,499
|
|
14
|
+
automation/builtins/action/log.py,sha256=f9ZnLVZ8WcQI-Mj_qpZGY41Vcq7tRX9GUHR1pQxVejg,513
|
|
15
|
+
automation/builtins/action/set_attribute.py,sha256=kpUmCvqDYIno73p3w1YzRMuOEGQ3sKfkO2QWq0bCODw,965
|
|
16
|
+
automation/builtins/entity/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
automation/builtins/entity/time.py,sha256=Dh1I8-twm7M5pZFgQXb3CcW6T1m6fCKHPYVW1xS0rnI,723
|
|
18
|
+
automation/builtins/entity/variable.py,sha256=aGfN_AEPlFxcAjkzFFQUtwMreu0GTYmBC-hvKyDsSIQ,2928
|
|
19
|
+
automation/builtins/event/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
automation/builtins/event/_scheduled.py,sha256=EnoIvra4PUx-9aqn0Q9gLoNE_SSMiJEX-_V2ztc3YkI,804
|
|
21
|
+
automation/builtins/event/at.py,sha256=i0Lr0l89J1wvCHP9KG2yS37RSYvBkGC3_WCjSYhBn3A,726
|
|
22
|
+
automation/builtins/event/callback.py,sha256=NsaEnlTTI1PdJ1Za-KXgTO5wqQi49x5e1HAiJ8tD1_Q,2208
|
|
23
|
+
automation/builtins/event/every.py,sha256=Xqwu3Z_nM5EMYu1Vi29zCi2yB7sv1ul0v6oAIRsS0OA,1033
|
|
24
|
+
automation/builtins/event/state_changed.py,sha256=nPVQTuxqtF3zxzY7zu3KO3ddov-QSR9Mp7pJCrTubOM,2000
|
|
25
|
+
automation/core/__init__.py,sha256=YO2W5YwrRKmCG1ODsTX2G9_jLRZgxozIrP95XuUUy24,245
|
|
26
|
+
automation/core/action.py,sha256=MGSnVoPxW2K5WKBaen_vUdHDjcaCLXMmmnJioOJSzJ0,532
|
|
27
|
+
automation/core/base.py,sha256=uSSzsy63AHbZUUbc-2M_Y6jkpbCIVxUr9bJiUc7oeaQ,1872
|
|
28
|
+
automation/core/composite_action.py,sha256=6uO9LKVllfP-FB7nKsZ_Rq3sWVt0MdZ1dk45gjSlCDw,1452
|
|
29
|
+
automation/core/entity.py,sha256=NGXAWHDcJeKpiCOFvHmHm3a__Mi3LX1jgUTlPwAdj0c,5797
|
|
30
|
+
automation/core/event.py,sha256=SRdS5KTNJctiJPNeVnJd6cPCDBjWizbxtrCp84dimQo,2995
|
|
31
|
+
automation/core/event_context.py,sha256=YU1rTG83bJj78C9e-C-iWpnVpfB2n0B62L3we4bAa0s,345
|
|
32
|
+
automation/core/trigger.py,sha256=1wP6cvWvgk05-OpkvirUbTktx4seDtFiHGOHklu4ycw,5416
|
|
33
|
+
automation/listeners/__init__.py,sha256=qot61a5pfvfWpYwL-uKYrikfBPY9WP5MsUwIY4aLuzg,476
|
|
34
|
+
automation/listeners/base.py,sha256=Iqh6kIbgagMWy4qPNx-fccriVl0FwA2NX2g3dqcHZe8,1327
|
|
35
|
+
automation/listeners/console.py,sha256=NNyJWuBYQzHltcglZ1Q4BkY5RWM1dRLyfnGuhIejdoI,3172
|
|
36
|
+
automation/listeners/instance_schema.py,sha256=3_ALaqLUjYv2ubYoLWT_KG4Ru04tfhAZRd7M9iKjyvU,808
|
|
37
|
+
automation/listeners/record.py,sha256=zWgNBBdwg-JRiQjX7PfBczQ4-R4GpySBZUP0HdYAbLQ,887
|
|
38
|
+
automation/listeners/trace.py,sha256=sKGFj1spItSvJdAiVk3mQ4XVRm-lME7X89FAbEivQyk,3199
|
|
39
|
+
automation/listeners/type_schema.py,sha256=hEyGoVADpO8ePCG1LJiuB2yMj08xTa9pdUH0dI_Ilr8,793
|
|
40
|
+
python_library_automation-0.1.16.dist-info/METADATA,sha256=98o5Fwx1sJRsuOt1EBcl8yyljVn_PyEVqcx2ZASQTCU,247
|
|
41
|
+
python_library_automation-0.1.16.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
42
|
+
python_library_automation-0.1.16.dist-info/RECORD,,
|