python-library-automation 0.1.16__tar.gz

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.
Files changed (54) hide show
  1. python_library_automation-0.1.16/.gitignore +11 -0
  2. python_library_automation-0.1.16/PKG-INFO +8 -0
  3. python_library_automation-0.1.16/automation/__init__.py +25 -0
  4. python_library_automation-0.1.16/automation/assistant.py +141 -0
  5. python_library_automation-0.1.16/automation/builtins/__init__.py +27 -0
  6. python_library_automation-0.1.16/automation/builtins/action/__init__.py +0 -0
  7. python_library_automation-0.1.16/automation/builtins/action/call_entity_method.py +35 -0
  8. python_library_automation-0.1.16/automation/builtins/action/delay.py +18 -0
  9. python_library_automation-0.1.16/automation/builtins/action/log.py +20 -0
  10. python_library_automation-0.1.16/automation/builtins/action/set_attribute.py +27 -0
  11. python_library_automation-0.1.16/automation/builtins/entity/__init__.py +0 -0
  12. python_library_automation-0.1.16/automation/builtins/entity/time.py +29 -0
  13. python_library_automation-0.1.16/automation/builtins/entity/variable.py +81 -0
  14. python_library_automation-0.1.16/automation/builtins/event/__init__.py +0 -0
  15. python_library_automation-0.1.16/automation/builtins/event/_scheduled.py +34 -0
  16. python_library_automation-0.1.16/automation/builtins/event/at.py +27 -0
  17. python_library_automation-0.1.16/automation/builtins/event/callback.py +65 -0
  18. python_library_automation-0.1.16/automation/builtins/event/every.py +32 -0
  19. python_library_automation-0.1.16/automation/builtins/event/state_changed.py +58 -0
  20. python_library_automation-0.1.16/automation/core/__init__.py +13 -0
  21. python_library_automation-0.1.16/automation/core/action.py +22 -0
  22. python_library_automation-0.1.16/automation/core/base.py +59 -0
  23. python_library_automation-0.1.16/automation/core/composite_action.py +47 -0
  24. python_library_automation-0.1.16/automation/core/entity.py +178 -0
  25. python_library_automation-0.1.16/automation/core/event.py +87 -0
  26. python_library_automation-0.1.16/automation/core/event_context.py +10 -0
  27. python_library_automation-0.1.16/automation/core/trigger.py +151 -0
  28. python_library_automation-0.1.16/automation/errors.py +42 -0
  29. python_library_automation-0.1.16/automation/executor.py +46 -0
  30. python_library_automation-0.1.16/automation/hub.py +52 -0
  31. python_library_automation-0.1.16/automation/listeners/__init__.py +17 -0
  32. python_library_automation-0.1.16/automation/listeners/base.py +23 -0
  33. python_library_automation-0.1.16/automation/listeners/console.py +82 -0
  34. python_library_automation-0.1.16/automation/listeners/instance_schema.py +26 -0
  35. python_library_automation-0.1.16/automation/listeners/record.py +29 -0
  36. python_library_automation-0.1.16/automation/listeners/trace.py +70 -0
  37. python_library_automation-0.1.16/automation/listeners/type_schema.py +26 -0
  38. python_library_automation-0.1.16/automation/loader.py +131 -0
  39. python_library_automation-0.1.16/automation/renderer.py +311 -0
  40. python_library_automation-0.1.16/automation/schema.py +142 -0
  41. python_library_automation-0.1.16/automation/updater.py +84 -0
  42. python_library_automation-0.1.16/example-simple.bat +10 -0
  43. python_library_automation-0.1.16/examples/simple/__main__.py +27 -0
  44. python_library_automation-0.1.16/pyproject.toml +17 -0
  45. python_library_automation-0.1.16/test.bat +9 -0
  46. python_library_automation-0.1.16/tests/support.py +20 -0
  47. python_library_automation-0.1.16/tests/test_assistant.py +31 -0
  48. python_library_automation-0.1.16/tests/test_builder.py +43 -0
  49. python_library_automation-0.1.16/tests/test_expression_condition.py +103 -0
  50. python_library_automation-0.1.16/tests/test_expression_parser.py +38 -0
  51. python_library_automation-0.1.16/tests/test_hub.py +20 -0
  52. python_library_automation-0.1.16/tests/test_schema.py +179 -0
  53. python_library_automation-0.1.16/tests/test_trigger_flow.py +92 -0
  54. python_library_automation-0.1.16/update.bat +9 -0
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .env
9
+ .pytest_cache/
10
+ config.yaml
11
+ logs/
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-library-automation
3
+ Version: 0.1.16
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: pydantic>=2.0.0
6
+ Requires-Dist: python-library-registry
7
+ Requires-Dist: python-library-scheduler
8
+ Requires-Dist: python-library-watch-config
@@ -0,0 +1,25 @@
1
+ from .core import (
2
+ Action,
3
+ Entity,
4
+ Trigger,
5
+ Event,
6
+ )
7
+ from .assistant import Assistant
8
+ from .listeners import BaseListener, ConsoleListener, TraceListener, TypeSchemaListener, InstanceSchemaListener
9
+ from .renderer import Renderer
10
+
11
+ from . import builtins # noqa: F401 触发自动注册
12
+
13
+ __all__ = [
14
+ "Action",
15
+ "Entity",
16
+ "Trigger",
17
+ "Event",
18
+ "Assistant",
19
+ "BaseListener",
20
+ "ConsoleListener",
21
+ "TraceListener",
22
+ "TypeSchemaListener",
23
+ "InstanceSchemaListener",
24
+ "Renderer",
25
+ ]
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from configlib import load_config
9
+ from watch_config import WatchConfig
10
+
11
+ from automation.hub import Hub, State
12
+ from automation.core import Entity, Event, Trigger, BaseAutomation
13
+ from automation.core.composite_action import CompositeAction
14
+ from automation import loader, updater, schema
15
+ from automation.listeners import BaseListener
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class Assistant:
21
+ def __init__(self, listeners: list[BaseListener] | BaseListener | None = None) -> None:
22
+ self._hub = Hub()
23
+ if listeners is not None:
24
+ if isinstance(listeners, BaseListener):
25
+ listeners = [listeners]
26
+ self._hub.listeners = listeners
27
+ self._watcher: WatchConfig[dict] | None = None
28
+ self._loop: asyncio.AbstractEventLoop | None = None
29
+ self._reload_lock = asyncio.Lock()
30
+
31
+ @property
32
+ def entities(self) -> dict[str, Entity]:
33
+ return self._hub.entities
34
+
35
+ @property
36
+ def events(self) -> dict[str, Event]:
37
+ return self._hub.events
38
+
39
+ @property
40
+ def actions(self) -> dict[str, CompositeAction]:
41
+ return self._hub.actions
42
+
43
+ @property
44
+ def triggers(self) -> dict[str, Trigger]:
45
+ return self._hub.triggers
46
+
47
+ def section(self, name: str) -> dict[str, BaseAutomation]:
48
+ return self._hub.section(name)
49
+
50
+ async def load(
51
+ self, source: str | Path | dict, watch: bool = False
52
+ ) -> Assistant:
53
+ if watch and isinstance(source, dict):
54
+ raise TypeError(
55
+ "watch=True requires a file path, dict is not supported"
56
+ )
57
+ config = _read_source(source)
58
+ await loader.load(self._hub, config)
59
+ self._hub.notify("on_loaded", self._hub)
60
+ if watch:
61
+ self._watcher = WatchConfig(Path(source), dict)
62
+ self._watcher(self._on_config_change)
63
+ return self
64
+
65
+ async def start(self) -> Assistant:
66
+ if self._hub.state == State.RUNNING:
67
+ return self
68
+ self._hub.state = State.RUNNING
69
+ self._hub.stop_event.clear()
70
+ self._loop = asyncio.get_running_loop()
71
+ for section_name in self._hub.AUTOMATION_SECTIONS:
72
+ for obj in self._hub.section(section_name).values():
73
+ await obj.on_start()
74
+ if self._watcher is not None:
75
+ self._watcher.start()
76
+ self._hub.notify("on_start")
77
+ return self
78
+
79
+ async def run(self) -> Assistant:
80
+ await self.start()
81
+ await self._hub.stop_event.wait()
82
+ return self
83
+
84
+ async def stop(self) -> None:
85
+ if self._hub.state != State.RUNNING:
86
+ return
87
+ self._hub.state = State.STOPPED
88
+ if self._watcher:
89
+ self._watcher.stop()
90
+ for section_name in reversed(self._hub.AUTOMATION_SECTIONS):
91
+ for obj in self._hub.section(section_name).values():
92
+ await obj.on_stop()
93
+ self._hub.stop_event.set()
94
+ self._hub.notify("on_stop")
95
+
96
+ async def update(self, source: str | Path | dict) -> None:
97
+ """手动热更新"""
98
+ new_config = _read_source(source)
99
+ await self._apply_reload(new_config)
100
+
101
+ def watch(self, path: str | Path, interval: float = 2.0) -> Assistant:
102
+ """设置文件监控"""
103
+ if self._watcher:
104
+ self._watcher.stop()
105
+ self._watcher = WatchConfig(Path(path), dict, interval=interval)
106
+ self._watcher(self._on_config_change)
107
+ if self._hub.state == State.RUNNING:
108
+ self._watcher.start()
109
+ return self
110
+
111
+ def _on_config_change(self, cfg: dict, changelog) -> None:
112
+ if self._loop is None or self._loop.is_closed():
113
+ return
114
+ asyncio.run_coroutine_threadsafe(
115
+ self._apply_reload(cfg), self._loop
116
+ )
117
+
118
+ async def _apply_reload(self, new_config: dict) -> None:
119
+ async with self._reload_lock:
120
+ old_config = self._hub.config
121
+ if old_config == new_config:
122
+ return
123
+ await updater.apply_diff(self._hub, old_config, new_config)
124
+ self._hub.config = new_config
125
+ self._hub.notify("on_loaded", self._hub)
126
+
127
+ @staticmethod
128
+ def export_schema() -> dict:
129
+ from automation.schema import export_type_schema
130
+ return export_type_schema()
131
+
132
+
133
+ def _read_source(source: str | Path | dict) -> dict[str, Any]:
134
+ if isinstance(source, (str, Path)):
135
+ data = load_config(str(source))
136
+ if not isinstance(data, dict):
137
+ raise TypeError(
138
+ f"Config root must be a dict, got {type(data).__name__}"
139
+ )
140
+ return data
141
+ return source
@@ -0,0 +1,27 @@
1
+ import importlib
2
+ import pkgutil
3
+ from pathlib import Path
4
+
5
+ def _auto_import():
6
+ """自动导入 builtins 下所有子模块,触发类的注册"""
7
+ package_dir = Path(__file__).resolve().parent
8
+ for sub_pkg in package_dir.iterdir():
9
+ if not sub_pkg.is_dir() or sub_pkg.name.startswith("_"):
10
+ continue
11
+ sub_package = f"{__name__}.{sub_pkg.name}"
12
+ try:
13
+ pkg = importlib.import_module(sub_package)
14
+ except ImportError:
15
+ continue
16
+ pkg_path = getattr(pkg, "__path__", None)
17
+ if pkg_path is None:
18
+ continue
19
+ for _finder, module_name, _is_pkg in pkgutil.walk_packages(
20
+ pkg_path, prefix=f"{sub_package}."
21
+ ):
22
+ try:
23
+ importlib.import_module(module_name)
24
+ except ImportError:
25
+ pass
26
+
27
+ _auto_import()
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+ import inspect
3
+ from typing import ClassVar, TYPE_CHECKING
4
+ from pydantic import Field
5
+ from automation.core import Action
6
+
7
+ if TYPE_CHECKING:
8
+ from automation.renderer import Renderer
9
+
10
+
11
+ class CallEntityMethod(Action):
12
+ _abstract: ClassVar[bool] = False
13
+ _type: ClassVar[str] = "call_entity_method"
14
+
15
+ entity: str = Field(description="实体名称")
16
+ method: str = Field(description="方法名称")
17
+ args: dict = Field(default_factory=dict, description="方法参数")
18
+
19
+ async def execute(self, renderer: Renderer) -> None:
20
+ hub = renderer._hub
21
+ if self.entity not in hub.entities:
22
+ raise ValueError(f"Entity {self.entity!r} not found")
23
+ entity = hub.entities[self.entity]
24
+ if not hasattr(entity, self.method):
25
+ raise ValueError(
26
+ f"Entity {self.entity!r} has no method {self.method!r}"
27
+ )
28
+ method = getattr(entity, self.method)
29
+ if not callable(method):
30
+ raise ValueError(
31
+ f"Entity {self.entity!r}.{self.method} is not callable"
32
+ )
33
+ result = method(**self.args)
34
+ if inspect.isawaitable(result):
35
+ await result
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+ from typing import ClassVar, TYPE_CHECKING
3
+ import asyncio
4
+ from pydantic import Field
5
+ from automation.core import Action
6
+
7
+ if TYPE_CHECKING:
8
+ from automation.renderer import Renderer
9
+
10
+
11
+ class DelayAction(Action):
12
+ _type: ClassVar[str] = "delay"
13
+ _abstract: ClassVar[bool] = False
14
+
15
+ seconds: float = Field(ge=0, description="等待秒数")
16
+
17
+ async def execute(self, renderer: Renderer) -> None:
18
+ await asyncio.sleep(self.seconds)
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+ from typing import ClassVar, TYPE_CHECKING
3
+ import logging
4
+ from pydantic import Field
5
+ from automation.core import Action
6
+
7
+ if TYPE_CHECKING:
8
+ from automation.renderer import Renderer
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class LogAction(Action):
14
+ _type: ClassVar[str] = "log"
15
+ _abstract: ClassVar[bool] = False
16
+
17
+ info: str = Field(description="日志内容")
18
+
19
+ async def execute(self, renderer: Renderer) -> None:
20
+ logger.info(self.info)
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+ from typing import Any, ClassVar, TYPE_CHECKING
3
+ from pydantic import Field
4
+ from automation.core import Action
5
+
6
+ if TYPE_CHECKING:
7
+ from automation.renderer import Renderer
8
+
9
+
10
+ class SetAttributeAction(Action):
11
+ _abstract: ClassVar[bool] = False
12
+ _type: ClassVar[str] = "set_attribute"
13
+
14
+ entity: str = Field(description="实体名称")
15
+ attribute: str = Field(description="属性名称")
16
+ value: Any = Field(description="新值")
17
+
18
+ async def execute(self, renderer: Renderer) -> None:
19
+ hub = renderer._hub
20
+ if self.entity not in hub.entities:
21
+ raise ValueError(f"Entity {self.entity!r} not found")
22
+ entity = hub.entities[self.entity]
23
+ if not hasattr(entity, self.attribute):
24
+ raise ValueError(
25
+ f"Entity {self.entity!r} has no attribute {self.attribute!r}"
26
+ )
27
+ setattr(entity, self.attribute, self.value)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+ from datetime import datetime
3
+ from typing import ClassVar
4
+ from automation.core import Entity
5
+
6
+
7
+ class TimeEntity(Entity):
8
+ _abstract: ClassVar[bool] = False
9
+ _type: ClassVar[str] = "time"
10
+
11
+ @property
12
+ def hour(self) -> int:
13
+ """当前小时 (0-23)"""
14
+ return datetime.now().hour
15
+
16
+ @property
17
+ def minute(self) -> int:
18
+ """当前分钟 (0-59)"""
19
+ return datetime.now().minute
20
+
21
+ @property
22
+ def second(self) -> int:
23
+ """当前秒 (0-59)"""
24
+ return datetime.now().second
25
+
26
+ @property
27
+ def weekday(self) -> int:
28
+ """星期几 (0=周一, 6=周日)"""
29
+ return datetime.now().weekday()
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+ from typing import Any, ClassVar, TYPE_CHECKING
3
+ from pydantic import Field, PrivateAttr
4
+ from automation.core import Entity
5
+ from automation.core.entity import AttributeInfo
6
+
7
+ if TYPE_CHECKING:
8
+ from automation.hub import Hub
9
+
10
+ TYPE_MAP = {"int": int, "float": float, "str": str, "bool": bool}
11
+
12
+
13
+ class VariableEntity(Entity):
14
+ _abstract: ClassVar[bool] = False
15
+ _type: ClassVar[str] = "variable"
16
+
17
+ properties: dict[str, dict[str, Any]] = Field(
18
+ default_factory=dict,
19
+ description="变量定义:{name: {type: str, default: value}}",
20
+ )
21
+
22
+ _values: dict[str, Any] = PrivateAttr(default_factory=dict)
23
+
24
+ async def on_validate(self, hub: Hub) -> None:
25
+ for name, spec in self.properties.items():
26
+ type_name = spec.get("type", "str")
27
+ value = spec.get("value")
28
+ if type_name == "list":
29
+ self._values[name] = hub.renderer.render_value(value or [])
30
+ else:
31
+ self._values[name] = self._cast(type_name, value)
32
+
33
+ def get_attributes(self) -> tuple[AttributeInfo, ...]:
34
+ return tuple(
35
+ AttributeInfo(
36
+ name=name,
37
+ type=spec.get("type", "str"),
38
+ description=spec.get("description", ""),
39
+ readonly=False,
40
+ default=spec.get("default"),
41
+ )
42
+ for name, spec in self.properties.items()
43
+ )
44
+
45
+ def get_attribute_values(self) -> dict[str, Any]:
46
+ return dict(self._values)
47
+
48
+ def __getattr__(self, name: str) -> Any:
49
+ if not name.startswith("_"):
50
+ priv = self.__dict__.get("__pydantic_private__")
51
+ if priv and "_values" in priv and name in priv["_values"]:
52
+ return priv["_values"][name]
53
+ return super().__getattr__(name)
54
+
55
+ def __setattr__(self, name: str, value: Any) -> None:
56
+ priv = self.__dict__.get("__pydantic_private__")
57
+ if priv is not None and "_values" in priv and name in priv["_values"]:
58
+ old = priv["_values"][name]
59
+ type_name = self.properties[name].get("type", "str")
60
+ if type_name == "list":
61
+ new = list(value) if not isinstance(value, list) else value
62
+ else:
63
+ new = self._cast(type_name, value)
64
+ priv["_values"][name] = new
65
+ try:
66
+ changed = old != new
67
+ except Exception:
68
+ changed = True
69
+ if changed:
70
+ self._notify_change(name, old, new)
71
+ return
72
+ super().__setattr__(name, value)
73
+
74
+ @staticmethod
75
+ def _cast(type_name: str, value: Any) -> Any:
76
+ if value is None:
77
+ return None
78
+ caster = TYPE_MAP.get(type_name)
79
+ if caster is not None:
80
+ return caster(value)
81
+ return value
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import ClassVar, TYPE_CHECKING, Any
4
+
5
+ from pydantic import PrivateAttr
6
+
7
+ from automation.core import Event
8
+
9
+ if TYPE_CHECKING:
10
+ from automation.hub import Hub
11
+
12
+
13
+ class ScheduledEvent(Event):
14
+ _abstract: ClassVar[bool] = True
15
+
16
+ _job: Any = PrivateAttr(default=None)
17
+
18
+ def _create_job(self) -> Any:
19
+ raise NotImplementedError
20
+
21
+ async def on_activate(self, hub: Hub) -> None:
22
+ if self._job:
23
+ await self._job.stop()
24
+ job = self._create_job()
25
+ job.add(self.fire)
26
+ self._job = job
27
+
28
+ async def on_start(self) -> None:
29
+ if self._job:
30
+ await self._job.start()
31
+
32
+ async def on_stop(self) -> None:
33
+ if self._job:
34
+ await self._job.stop()
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import ClassVar
4
+
5
+ from pydantic import Field
6
+
7
+ from ._scheduled import ScheduledEvent
8
+ from scheduler import At
9
+
10
+
11
+ class AtEvent(ScheduledEvent):
12
+ _type: ClassVar[str] = "at"
13
+
14
+ weekday: int | None = Field(default=None, ge=0, le=6)
15
+ hour: int = Field(default=0, ge=0, le=23)
16
+ minute: int = Field(default=0, ge=0, le=59)
17
+ second: int = Field(default=0, ge=0, le=59)
18
+ max_runs: int | None = Field(default=None, ge=1)
19
+
20
+ def _create_job(self) -> At:
21
+ return At(
22
+ weekday=self.weekday,
23
+ hour=self.hour,
24
+ minute=self.minute,
25
+ second=self.second,
26
+ max_runs=self.max_runs,
27
+ )
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+ from typing import Any, ClassVar, TYPE_CHECKING
3
+
4
+ from pydantic import Field, PrivateAttr
5
+
6
+ from automation.core.event import Event
7
+ from automation.core.event_context import EventContext
8
+
9
+ if TYPE_CHECKING:
10
+ from automation.hub import Hub
11
+
12
+
13
+ class CallbackEvent(Event):
14
+ _abstract: ClassVar[bool] = False
15
+ _type: ClassVar[str] = "callback"
16
+
17
+ entity_type: str = Field(description="实体类型名")
18
+ callback: str = Field(description="实体上的回调属性名")
19
+
20
+ _entity_ref: Any = PrivateAttr(default=None)
21
+ _original_callbacks: dict[str, Any] = PrivateAttr(default_factory=dict)
22
+
23
+ @property
24
+ def entity(self) -> Any:
25
+ """触发后可引用的实际 entity 实例"""
26
+ return self._entity_ref
27
+
28
+ def _find_entities(self, hub: Hub) -> list:
29
+ return [
30
+ e for e in hub.entities.values()
31
+ if e._type == self.entity_type
32
+ ]
33
+
34
+ async def on_validate(self, hub: Hub) -> None:
35
+ if not self._find_entities(hub):
36
+ raise ValueError(
37
+ f"No entity of type {self.entity_type!r} found"
38
+ )
39
+
40
+ async def on_activate(self, hub: Hub) -> None:
41
+ event_ref = self
42
+ self._original_callbacks.clear()
43
+
44
+ for entity in self._find_entities(hub):
45
+ self._original_callbacks[entity.instance_name] = getattr(
46
+ entity, self.callback, None
47
+ )
48
+
49
+ async def wrapper(_entity=entity, **kwargs):
50
+ event_ref._entity_ref = _entity
51
+ context = EventContext(
52
+ event_name=event_ref.instance_name,
53
+ data={"entity": _entity, **kwargs},
54
+ )
55
+ await event_ref.fire(context)
56
+
57
+ setattr(entity, self.callback, wrapper)
58
+
59
+ async def on_stop(self) -> None:
60
+ for name, original in self._original_callbacks.items():
61
+ entity = self._hub.entities.get(name)
62
+ if entity is not None and original is not None:
63
+ setattr(entity, self.callback, original)
64
+ self._original_callbacks.clear()
65
+ self._entity_ref = None
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+ from typing import ClassVar
3
+
4
+ from pydantic import Field
5
+
6
+ from ._scheduled import ScheduledEvent
7
+ from scheduler import Every
8
+
9
+ class EveryEvent(ScheduledEvent):
10
+ _type: ClassVar[str] = "every"
11
+
12
+ seconds: float = Field(default=0, ge=0)
13
+ minutes: float = Field(default=0, ge=0)
14
+ hours: float = Field(default=0, ge=0)
15
+ days: float = Field(default=0, ge=0)
16
+ immediate: bool = Field(default=False)
17
+ max_runs: int | None = Field(default=None, ge=1)
18
+
19
+ async def on_validate(self, hub) -> None:
20
+ total = self.seconds + self.minutes * 60 + self.hours * 3600 + self.days * 86400
21
+ if total <= 0:
22
+ raise ValueError("EveryEvent requires at least one interval > 0")
23
+
24
+ def _create_job(self) -> Every:
25
+ return Every(
26
+ seconds=self.seconds,
27
+ minutes=self.minutes,
28
+ hours=self.hours,
29
+ days=self.days,
30
+ immediate=self.immediate,
31
+ max_runs=self.max_runs,
32
+ )
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+ from typing import Any, ClassVar, TYPE_CHECKING
3
+
4
+ from pydantic import Field, PrivateAttr
5
+
6
+ from automation.core.event import Event
7
+ from automation.core.event_context import EventContext
8
+
9
+ if TYPE_CHECKING:
10
+ from automation.hub import Hub
11
+
12
+
13
+ class StateChangedEvent(Event):
14
+ _abstract: ClassVar[bool] = False
15
+ _type: ClassVar[str] = "state_changed"
16
+
17
+ entity_type: str | None = Field(default=None, description="过滤实体类型(可选)")
18
+ entity_name: str | None = Field(default=None, description="过滤实体实例名(可选)")
19
+ attribute: str | None = Field(default=None, description="过滤属性名(可选)")
20
+
21
+ _entity_ref: Any = PrivateAttr(default=None)
22
+ _handler: Any = PrivateAttr(default=None)
23
+
24
+ @property
25
+ def entity(self) -> Any:
26
+ return self._entity_ref
27
+
28
+ async def on_activate(self, hub: Hub) -> None:
29
+ event_ref = self
30
+
31
+ async def handler(entity, attr, old, new):
32
+ if event_ref.entity_type and entity._type != event_ref.entity_type:
33
+ return
34
+ if event_ref.entity_name and entity.instance_name != event_ref.entity_name:
35
+ return
36
+ if event_ref.attribute and attr != event_ref.attribute:
37
+ return
38
+ event_ref._entity_ref = entity
39
+ context = EventContext(
40
+ event_name=event_ref.instance_name,
41
+ data={
42
+ "entity": entity,
43
+ "attribute": attr,
44
+ "old": old,
45
+ "new": new,
46
+ },
47
+ )
48
+ await event_ref.fire(context)
49
+
50
+ self._handler = handler
51
+ hub._on_state_changed.append(handler)
52
+
53
+ async def on_stop(self) -> None:
54
+ if self._handler and self._hub:
55
+ try:
56
+ self._hub._on_state_changed.remove(self._handler)
57
+ except ValueError:
58
+ pass
@@ -0,0 +1,13 @@
1
+ from .base import BaseAutomation
2
+ from .action import Action
3
+ from .entity import Entity
4
+ from .trigger import Trigger
5
+ from .event import Event
6
+
7
+ __all__ = [
8
+ "BaseAutomation",
9
+ "Action",
10
+ "Entity",
11
+ "Trigger",
12
+ "Event",
13
+ ]